Skip to content

Commit 5b2d776

Browse files
committed
Bugfix FXIOS-16715 Keep a translation from landing on the wrong document
translateCurrentPage resolved the tab's WKWebView, awaited the model prewarm, then called into the page. Nothing between the await and the JS call checked for cancellation or for the document having changed, and a tab's WKWebView is a single object that outlives its documents. When the prewarm was slow, which happens the first time a language pair is used, the translation was applied to whatever page the user had navigated to. The page now mints a document identifier that native captures before the prewarm and hands back when starting; startTranslations refuses a request addressed to a different document. A cancellation check after the prewarm covers the case where the task is cancelled while the model downloads. Navigating away is not a failure, so the request is dropped silently rather than showing a translation error on the new page.
1 parent 192ba4e commit 5b2d776

5 files changed

Lines changed: 120 additions & 7 deletions

File tree

firefox-ios/Client/Frontend/Translations/Service/TranslationsService.swift

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,19 @@ final class TranslationsService: TranslationsServiceProtocol {
6868
}
6969
onLanguageIdentified?(pageLanguage, targetLanguage)
7070
let webView = try currentWebView(for: windowUUID)
71+
// The tab's WKWebView outlives its documents, and prewarming can take seconds.
72+
let expectedDocumentId = try await currentDocumentIdJS(on: webView)
7173
// Prewarm resources prior to calling the JS translation API.
7274
await modelsFetcher.prewarmResources(for: pageLanguage, to: targetLanguage)
75+
try Task.checkCancellation()
7376
// Create a bridge to the translations engine.
7477
_ = translationsEngine.bridge(to: webView)
75-
try await startTranslationsJS(on: webView, from: pageLanguage, to: targetLanguage)
78+
try await startTranslationsJS(
79+
on: webView,
80+
from: pageLanguage,
81+
to: targetLanguage,
82+
expecting: expectedDocumentId
83+
)
7684
}
7785

7886
/// Checks whether initial translation output has been produced.
@@ -101,20 +109,50 @@ final class TranslationsService: TranslationsServiceProtocol {
101109
}
102110
}
103111

112+
private func currentDocumentIdJS(on webView: WKWebView) async throws -> String {
113+
let failure = TranslationsServiceError.jsEvaluationFailed(
114+
reason: "JS evaluation failed: currentDocumentIdJS"
115+
)
116+
let js = "return window.__firefox__.Translations.documentId()"
117+
let result: Any?
118+
do {
119+
result = try await webView.callAsyncJavaScript(js, contentWorld: .defaultClient)
120+
} catch {
121+
logger.log(
122+
"Could not read the document id: \(error.localizedDescription)",
123+
level: .warning,
124+
category: .translations
125+
)
126+
throw failure
127+
}
128+
129+
guard let documentId = result as? String, !documentId.isEmpty else { throw failure }
130+
return documentId
131+
}
132+
104133
/// Starts translations by calling into the JS bridge.
134+
/// Throws `.documentChanged` when the page navigated since the translation was requested.
105135
private func startTranslationsJS(on webView: WKWebView,
106136
from: String,
107-
to: String) async throws {
108-
let jsArgs = "{from: \"\(from)\", to: \"\(to)\"}"
109-
let js = "window.__firefox__.Translations.startTranslations(\(jsArgs))"
137+
to: String,
138+
expecting documentId: String) async throws {
139+
let jsArgs = "{from: \"\(from)\", to: \"\(to)\", expectedDocumentId: \"\(documentId)\"}"
140+
let js = "return window.__firefox__.Translations.startTranslations(\(jsArgs))"
110141

142+
let result: Any?
111143
do {
112-
_ = try await webView.callAsyncJavaScript(js, contentWorld: .defaultClient)
144+
result = try await webView.callAsyncJavaScript(js, contentWorld: .defaultClient)
113145
} catch {
114146
/// NOTE: It would be safe to pass in the js string directly here, but it would just add too much noise
115147
/// since from and to could be any language code. We only care that startTranslationsJS failed.
116148
throw TranslationsServiceError.jsEvaluationFailed(reason: "JS evaluation failed: startTranslationsJS")
117149
}
150+
151+
// Anything other than an explicit answer means the bridge is broken, not a navigation.
152+
guard let didStart = result as? Bool else {
153+
throw TranslationsServiceError.jsEvaluationFailed(reason: "JS evaluation failed: startTranslationsJS")
154+
}
155+
guard didStart else { throw TranslationsServiceError.documentChanged }
118156
}
119157

120158
/// Evaluates the JS hook to check whether initial translation output has been produced.

firefox-ios/Client/Frontend/Translations/Service/TranslationsServiceError.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
/// Errors thrown by `TranslationsService` when preconditions or WebView state are invalid.
66
enum TranslationsServiceError: Error, Equatable {
77
case missingWebView
8+
/// The page navigated before the translation started, so the request is dropped.
9+
case documentChanged
810
case jsEvaluationFailed(reason: String)
911
case pageLanguageDetectionFailed(description: String)
1012
case unknown(domain: String, code: Int)
@@ -25,6 +27,8 @@ enum TranslationsServiceError: Error, Equatable {
2527
switch self {
2628
case .missingWebView:
2729
return "missing_webview"
30+
case .documentChanged:
31+
return "document_changed"
2832
case .jsEvaluationFailed(let reason):
2933
/// reason is already a stable token like "JS evaluation failed: startTranslationsJS"
3034
return "js_evaluation_failed(\(reason))"

firefox-ios/Client/Frontend/Translations/TranslationsMiddleware.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,17 @@ final class TranslationsMiddleware: FeatureFlaggable, Notifiable {
654654
} catch {
655655
guard !Task.isCancelled else { return }
656656
let serviceError = TranslationsServiceError.fromUnknown(error)
657+
// Not a failure, but the icon and the telemetry flow still have to be closed out.
658+
if serviceError == .documentChanged {
659+
logger.log(
660+
"Translation dropped because the page changed before it started.",
661+
level: .info,
662+
category: .translations
663+
)
664+
dispatchClearTranslationIcon(windowUUID: windowUUID, on: tab)
665+
translationFlowIds[windowUUID] = nil
666+
return
667+
}
657668
translationsTelemetry.translationFailed(
658669
translationFlowId: flowId(for: windowUUID),
659670
errorType: serviceError.telemetryDescription

firefox-ios/Client/Frontend/UserContent/UserScripts/MainFrame/AtDocumentStart/TranslationsEntrypoint.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,14 @@ const reportPageState = () => {
7272

7373
window.addEventListener("pageshow", reportPageState);
7474

75+
const documentId = () => innerWindowId;
76+
7577
/// NOTE: This should be called to start the translation process for this document.
7678
/// This creates the TranslationsDocument instance that manages the translation lifecycle.
77-
const startTranslations = ({from, to}) => {
79+
const startTranslations = ({from, to, expectedDocumentId}) => {
80+
if (expectedDocumentId !== innerWindowId) {
81+
return false;
82+
}
7883
pendingTranslation = { from, to };
7984
resetIsDone();
8085
const languagePair = {sourceLanguage: from, targetLanguage: to}
@@ -98,6 +103,7 @@ const startTranslations = ({from, to}) => {
98103
innerWindowId,
99104
};
100105
sendToEngine(message);
106+
return true;
101107
};
102108

103109
/// NOTE: This should be called when we teardown the translations for this document.
@@ -132,5 +138,5 @@ Object.defineProperty(window.__firefox__, "Translations", {
132138
enumerable: false,
133139
configurable: false,
134140
writable: false,
135-
value: Object.freeze({ getLanguageSampleWhenReady, startTranslations, isDone, discardTranslations })
141+
value: Object.freeze({ getLanguageSampleWhenReady, documentId, startTranslations, isDone, discardTranslations })
136142
});

firefox-ios/firefox-ios-tests/Tests/ClientTests/TranslationsTests/TranslationsMiddlewareTests.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,60 @@ final class TranslationsMiddlewareIntegrationTests: XCTestCase, StoreTestUtility
656656
XCTAssertEqual(mockStore.dispatchedActions.count, 0)
657657
}
658658

659+
func test_didSelectTargetLanguage_whenDocumentChanged_doesNotDispatchError() throws {
660+
setTranslationsFeatureEnabled(enabled: true)
661+
let mockTranslationsService = MockTranslationsService(
662+
translateResult: .failure(TranslationsServiceError.documentChanged)
663+
)
664+
let subject = createSubject(translationsService: mockTranslationsService)
665+
let action = TranslationLanguageSelectedAction(
666+
windowUUID: .XCTestDefaultUUID,
667+
targetLanguage: "de",
668+
actionType: TranslationsActionType.didSelectTargetLanguage
669+
)
670+
671+
let loadingExpectation = XCTestExpectation(description: "didStartTranslatingPage dispatched")
672+
mockStore.dispatchCalled = { [weak mockStore] in
673+
if (mockStore?.dispatchedActions.last?.actionType as? TranslationsActionType) == .didStartTranslatingPage {
674+
loadingExpectation.fulfill()
675+
}
676+
}
677+
678+
subject.translationsProvider.legacyMiddleware(mockStore.state, action)
679+
wait(for: [loadingExpectation], timeout: 1.0)
680+
681+
// The user navigated away before the translation started. The page they are on now was
682+
// never translated, so it must not be told that a translation failed.
683+
let errorExpectation = XCTestExpectation(description: "didReceiveErrorTranslating should not be dispatched")
684+
errorExpectation.isInverted = true
685+
mockStore.dispatchCalled = { [weak mockStore] in
686+
if (mockStore?.dispatchedActions.last?.actionType as? TranslationsActionType) == .didReceiveErrorTranslating {
687+
errorExpectation.fulfill()
688+
}
689+
}
690+
wait(for: [errorExpectation], timeout: 1.0)
691+
692+
let errorActions = mockStore.dispatchedActions.filter {
693+
($0.actionType as? TranslationsActionType) == .didReceiveErrorTranslating
694+
}
695+
XCTAssertTrue(errorActions.isEmpty)
696+
697+
let toastActions = mockStore.dispatchedActions.filter {
698+
($0.actionType as? GeneralBrowserActionType) == .showToast
699+
}
700+
XCTAssertTrue(toastActions.isEmpty)
701+
702+
XCTAssertEqual(mockTranslationsTelemetry.translationFailedCalledCount, 0)
703+
704+
// Dropping the request must still take the icon off `.loading`, or the spinner never ends.
705+
let tab = try XCTUnwrap(mockTabManager.selectedTab)
706+
XCTAssertNotEqual(tab.translationConfiguration?.state, .loading)
707+
let clearActions = mockStore.dispatchedActions.filter {
708+
($0.actionType as? TranslationsActionType) == .receivedTranslationLanguage
709+
}
710+
XCTAssertFalse(clearActions.isEmpty)
711+
}
712+
659713
func test_didSelectTargetLanguage_withTranslationError_dispatchToastAction() throws {
660714
setTranslationsFeatureEnabled(enabled: true)
661715
enum TestError: Error { case example }

0 commit comments

Comments
 (0)