Skip to content

Commit f97211b

Browse files
committed
Prevent retry after failed Google Sign-In init
Add a poisoned-init guard to GoogleSignInBootstrap: track failed initialization attempts with _attemptFailed, surface a clear StateError advising a page reload, and clear in-flight futures to avoid hanging clients. Transient failures still allow retries, but web-specific failures that leave the plugin half-initialized are now treated as non-retryable in-session. Reset logic for tests was updated. Also warm the Google SDK earlier for Sheets: call _warmGoogleIfSheets on screen init and when re-enabling the contact form (and check _contactFormEnabled), so existing Sheets-configured sites or re-enabled forms prime the SDK. A unit test was added to cover the poisoned-plugin scenario.
1 parent 54eb8c4 commit f97211b

4 files changed

Lines changed: 102 additions & 35 deletions

File tree

lib/core/services/google_signin_bootstrap.dart

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class GoogleSignInBootstrap {
3232
GoogleSignInBootstrap._();
3333

3434
static bool _done = false;
35+
static bool _attemptFailed = false;
3536
static Future<void>? _inFlight;
3637
static String? _usedClientId;
3738
static String? _usedServerClientId;
@@ -80,19 +81,35 @@ class GoogleSignInBootstrap {
8081
serverClientId: serverClientId,
8182
);
8283
} on StateError catch (e) {
83-
// The double-init guard is the only StateError `initialize()` raises, so
84-
// arriving here means the singleton is already configured — by a code
85-
// path this class does not know about, or by the previous run of a hot
84+
if (_attemptFailed) {
85+
// We have already watched an initialize() fail, so this is not a
86+
// healthy "someone else got here first". The web plugin raises its
87+
// double-init flag BEFORE awaiting the GIS script, so an attempt that
88+
// fails at that await leaves the plugin permanently half-initialized:
89+
// it rejects every retry with this same StateError while the future
90+
// its other methods await never completes. Reporting success here
91+
// would hand the caller a client that hangs instead of answering.
92+
// Nothing in-process can clear that state — only a fresh page can.
93+
_inFlight = null;
94+
throw StateError(
95+
'Google Sign-In could not finish initializing and cannot be retried '
96+
'in this session. Please reload the page and try again. ($e)',
97+
);
98+
}
99+
// Otherwise the singleton is simply already configured — by a code path
100+
// this class does not know about, or by the previous run of a hot
86101
// restart, which resets Dart state but not the GIS SDK already loaded
87-
// into the page. Either way it is initialized, which is all the caller
88-
// asked for. Note this is deliberately matched by TYPE, not by message:
89-
// the wording of that error changes between plugin versions.
102+
// into the page. Either way it is initialized, which is what the caller
103+
// asked for. Matched by TYPE, not by message: the wording of that error
104+
// changes between plugin versions.
90105
debugPrint('GoogleSignInBootstrap: initialize() reports an existing '
91106
'initialization; continuing. ($e)');
92107
} catch (e) {
93108
// Something transient — e.g. the GIS script failed to load. Drop the
94109
// cached future so the next caller retries instead of inheriting the
95-
// failure forever.
110+
// failure forever, but remember that an attempt failed: on web a retry
111+
// can only ever hit the poisoned-plugin case handled above.
112+
_attemptFailed = true;
96113
_inFlight = null;
97114
rethrow;
98115
}
@@ -105,6 +122,7 @@ class GoogleSignInBootstrap {
105122
@visibleForTesting
106123
static void resetForTest() {
107124
_done = false;
125+
_attemptFailed = false;
108126
_inFlight = null;
109127
_usedClientId = null;
110128
_usedServerClientId = null;

lib/features/websites/screens/generate_website_screen.dart

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,10 @@ class _GenerateWebsiteScreenState extends State<GenerateWebsiteScreen> {
327327
))
328328
.toList();
329329

330+
// An existing Sheets form reaches Publish without ever touching the
331+
// channel selector, so warm from the incoming configuration too.
332+
_warmGoogleIfSheets();
333+
330334
// Languages: keep only known options, cap at 3, default to English.
331335
final knownLangs = websiteLanguageAutonyms.keys.toSet();
332336
final initLangs = (widget.initialLanguages ?? const <String>[])
@@ -360,11 +364,13 @@ class _GenerateWebsiteScreenState extends State<GenerateWebsiteScreen> {
360364
setState(() => _pricing = pricing);
361365
}
362366

363-
/// Load the Google Sign-In SDK as soon as the user picks the channel that
364-
/// will need it, rather than inside the Publish handler. Failures are
365-
/// ignored: this is a head start, and Publish still initializes on demand.
367+
/// Load the Google Sign-In SDK as soon as we know it will be needed, rather
368+
/// than inside the Publish handler. Called from every path that can land on
369+
/// the Sheets channel — picking it, re-enabling a form that remembers it,
370+
/// and opening a website already configured with it. Failures are ignored:
371+
/// this is a head start, and Publish still initializes on demand.
366372
void _warmGoogleIfSheets() {
367-
if (_channel != ContactFormChannel.sheets) return;
373+
if (!_contactFormEnabled || _channel != ContactFormChannel.sheets) return;
368374
unawaited(AuthService.instance.ensureGoogleInitialized().catchError(
369375
(Object e) => debugPrint('Google Sign-In warm-up failed: $e'),
370376
));
@@ -889,16 +895,21 @@ class _GenerateWebsiteScreenState extends State<GenerateWebsiteScreen> {
889895
),
890896
Switch(
891897
value: _contactFormEnabled,
892-
onChanged: (v) => setState(() {
893-
_contactFormEnabled = v;
894-
if (v && _fields.isEmpty) _fields.add(_ContactFieldRow());
895-
// Default the message header to the website name, but only
896-
// when the creator hasn't typed one — so it stays editable
897-
// and clearable.
898-
if (v && _titleController.text.trim().isEmpty) {
899-
_titleController.text = _nameController.text.trim();
900-
}
901-
}),
898+
onChanged: (v) {
899+
setState(() {
900+
_contactFormEnabled = v;
901+
if (v && _fields.isEmpty) _fields.add(_ContactFieldRow());
902+
// Default the message header to the website name, but only
903+
// when the creator hasn't typed one — so it stays editable
904+
// and clearable.
905+
if (v && _titleController.text.trim().isEmpty) {
906+
_titleController.text = _nameController.text.trim();
907+
}
908+
});
909+
// Re-enabling a form that remembers Sheets never fires the
910+
// channel selector, so warm from here too.
911+
_warmGoogleIfSheets();
912+
},
902913
),
903914
],
904915
),

lib/web/screens/web_generate_website_screen.dart

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,9 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
235235
WebWebsiteService.instance.fetchPricing().then((p) {
236236
if (mounted && p != null) setState(() => _pricing = p);
237237
});
238+
// An existing Sheets form reaches Publish without ever touching the
239+
// channel selector, so warm from the incoming configuration too.
240+
_warmGoogleIfSheets();
238241
}
239242

240243
@override
@@ -288,13 +291,16 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
288291
return null;
289292
}
290293

291-
/// Load the Google Sign-In SDK as soon as the user picks the channel that
292-
/// will need it. On web this matters beyond speed: `authorizeScopes` opens a
293-
/// popup, and a browser only permits that while the click that led to it is
294-
/// still "recent" (a few seconds) — long enough for a cached SDK, not always
295-
/// for a cold one. Failures are ignored; Publish still initializes on demand.
294+
/// Load the Google Sign-In SDK as soon as we know it will be needed. On web
295+
/// this matters beyond speed: `authorizeScopes` opens a popup, and a browser
296+
/// only permits that while the click that led to it is still "recent" (a few
297+
/// seconds) — long enough for a cached SDK, not always for a cold one.
298+
/// Called from every path that can land on the Sheets channel: picking it,
299+
/// re-enabling a form that remembers it, and opening a website already
300+
/// configured with it. Failures are ignored; Publish still initializes on
301+
/// demand.
296302
void _warmGoogleIfSheets() {
297-
if (_channel != ContactFormChannel.sheets) return;
303+
if (!_contactFormEnabled || _channel != ContactFormChannel.sheets) return;
298304
unawaited(AuthService.instance.ensureGoogleInitialized().catchError(
299305
(Object e) => debugPrint('Google Sign-In warm-up failed: $e'),
300306
));
@@ -787,14 +793,20 @@ class _WebGenerateWebsiteScreenState extends State<WebGenerateWebsiteScreen> {
787793
style: TextStyle(fontSize: 11),
788794
),
789795
value: _contactFormEnabled,
790-
onChanged: (v) => setState(() {
791-
_contactFormEnabled = v;
792-
// Default the message header to the website name, but only when
793-
// the creator hasn't typed one — keeps it editable and clearable.
794-
if (v && _titleController.text.trim().isEmpty) {
795-
_titleController.text = _nameController.text.trim();
796-
}
797-
}),
796+
onChanged: (v) {
797+
setState(() {
798+
_contactFormEnabled = v;
799+
// Default the message header to the website name, but only
800+
// when the creator hasn't typed one — keeps it editable and
801+
// clearable.
802+
if (v && _titleController.text.trim().isEmpty) {
803+
_titleController.text = _nameController.text.trim();
804+
}
805+
});
806+
// Re-enabling a form that remembers Sheets never fires the
807+
// channel selector, so warm from here too.
808+
_warmGoogleIfSheets();
809+
},
798810
),
799811
if (_contactFormEnabled) ...[
800812
const SizedBox(height: 8),

test/unit/core/services/google_signin_bootstrap_test.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,32 @@ void main() {
137137
expect(GoogleSignInBootstrap.isInitialized, isTrue);
138138
});
139139

140+
test(
141+
'a web plugin poisoned by a failed first init is reported, not reported '
142+
'as success', () async {
143+
// google_sign_in_web raises its double-init flag BEFORE awaiting the GIS
144+
// script, so an attempt that fails at that await leaves the plugin
145+
// permanently rejecting retries while never finishing initialization.
146+
// Treating that rejection as "already initialized" would hand callers a
147+
// client that hangs.
148+
fake.failWith = Exception('GIS script failed to load');
149+
150+
await expectLater(
151+
GoogleSignInBootstrap.ensureInitialized(clientId: 'web-client'),
152+
throwsA(isA<Exception>()),
153+
);
154+
155+
await expectLater(
156+
GoogleSignInBootstrap.ensureInitialized(clientId: 'web-client'),
157+
throwsA(isA<StateError>().having(
158+
(StateError e) => e.message,
159+
'message',
160+
contains('reload'),
161+
)),
162+
);
163+
expect(GoogleSignInBootstrap.isInitialized, isFalse);
164+
});
165+
140166
test('a transient failure is surfaced and leaves a retry possible', () async {
141167
// A native-style plugin, so the retry below exercises a genuine second
142168
// initialization rather than the already-initialized branch.

0 commit comments

Comments
 (0)