Field notes / React Native / WebView / iOS / Debugging
Debugowanie WebView w aplikacjach mobilnych: New Relic, niewidoczny iframe i crash na iOS
Aplikacja napisana w React Native crashuje chwilę po zalogowaniu. Tylko na iOS. API zwraca poprawny URL, strona działa w przeglądarce, a reinstall aplikacji pomaga - przynajmniej na chwilę. W logach widoczne: about:blank is not a file URL.
To historia debugowania React Native WebView przy użyciu Xcode oraz Safari Web Inspector. Zaczyna się od natywnego wyjątku, prowadzi przez tymczasowy iframe tworzony przez New Relic Session Replay i kończy na dwóch błędach: w react-native-webview oraz w aplikacji. Razem zamieniły niewidoczną ramkę w crash całej aplikacji.
Na potrzeby prześledzenia problemu oraz tego artykułu przygotowałem sandbox - stronę internetową z New Relic Session Replay (sandbox), którą wyświetlam w aplikacji mobilnej. Aplikacja mobilna ma warunkowe ładowanie strony i widoczne logi, a repozytorium znajdziesz tutaj. Wszystkie logi przedstawione w artykule pochodzą z aplikacji sandboxowej.
Publiczny sandbox ma obecnie przypięte wersje: react-native-webview 13.16.2, React Native 0.86.3, React 19.2.3 i rejestrator New Relic SPA 1.323.0. Testowałem sandbox na iOS 18 i 26. To wersje sandboxa, nie twierdzenie o wersjach aplikacji, w której pierwotnie zaobserwowałem problem. Natywny fix zaproponowałem w PR #4045 do react-native-webview.
1. Crash na iOS, który znikał po reinstalacji(?)
Żeby lepiej zrozumieć architekturę aplikacji, wyobraź sobie aplikację mobilną, której ekran Home to PWA w WebView. W tym samym stosie nawigacyjnym znajduje się ekran Page - służy do otwierania innych linków jako nowych ekranów aplikacji:
navigation.navigate(Root.PAGE, {pageUrl})
Objaw wygląda tak: aplikacja na iOS crashuje się po zalogowaniu. Widać elementy ekranu Home, na przykład dolny pasek kart, ale sam ekran jest biały. Po ułamku sekundy aplikacja się zamyka. Po reinstalacji problem znika.
Zbudowałem aplikację w Xcode i odtworzyłem kroki. Xcode wskazał natywny wyjątek:
Stos wywołań z buildu sandboxa pokazał ten sam błąd:
[CoreFoundation] *** Terminating app due to uncaught exception
'NSInvalidArgumentException', reason: 'about:blank is not a file URL'
*** First throw call stack:
(
0 CoreFoundation 0x00000001804b910c __exceptionPreprocess + 172
1 libobjc.A.dylib 0x0000000180092da8 objc_exception_throw + 72
2 CoreFoundation 0x00000001804b901c -[NSException initWithCoder:] + 0
3 WebKit 0x000000018c4a8184 -[WKWebView loadFileURL:allowingReadAccessToURL:] + 140
4 FrameLabBroken.debug.dylib 0x00000001056254f0 __29-[RNCWebViewImpl visitSource]_block_invoke + 928
5 FrameLabBroken.debug.dylib 0x000000010562bebc -[RNCWebViewImpl writeCookiesToWebView:completion:] + 412
6 FrameLabBroken.debug.dylib 0x000000010562c418 -[RNCWebViewImpl syncCookiesToWebView:] + 148
7 FrameLabBroken.debug.dylib 0x00000001056250a0 -[RNCWebViewImpl visitSource] + 1196
8 FrameLabBroken.debug.dylib 0x00000001<…>
Xcode wskazał konkretne miejsce problemu w kodzie natywnym WebView.
Poprosiłem też o logi Jetsam z urządzeń, na których wystąpił problem. Okazało się, że ten crash ich nie generuje.
Widać, że aplikacja zamyka się podczas ładowania ekranu Home, bez kliknięcia w link. NSInvalidArgumentException i stos z loadFileURL potwierdzają natywne pochodzenie błędu.
Pierwsze podejrzenie: API zwraca nieprawidłowy URL, który aplikacja traktuje jak about:blank, albo zwraca about:blank bezpośrednio.
W tym przypadku API zwróciło prawidłowy URL, który działał w przeglądarce. Po reinstalacji aplikacja faktycznie zadziałała.
Przywróciłem poprzedni stan, który pozwalał mi na badanie crasha. W celu dokładnej analizy całego procesu obróbki URLa dodałem logi do całego flow WebView w aplikacji - od pobrania URL z API po załadowanie strony. Okazało się, że URL oraz jego obsługa wyglądały całkowicie dobrze, a mimo wszystko WebView próbowało otworzyć about:blank, co powodowało crash aplikacji.
2. Dlaczego WebView próbuje otworzyć about:blank jako plik
React Native WebView na iOS traktuje URL-e bez hostname jako local files (file://). Oznacza to, że próba otworzenia w WebView adresu about:blank powoduje spełnienie warunku w sprawdzeniu loadFileURL. Z oczywistych względów nie jest to plik lokalny, więc aplikacja crashuje się z błędem:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: 'about:blank is not a file URL'
Uproszczony fragment:
if (request.URL.host) {
[webView loadRequest:request];
} else {
[webView loadFileURL:request.URL
allowingReadAccessToURL:readAccessUrl];
}
Pierwszy fix to patch pliku, dzięki któremu about:blank nie jest otwierany jako local file.
Warto zabezpieczyć się też po stronie TypeScript:
export const isEmptyWebViewUrl = (url: string | null | undefined): boolean =>
!url?.trim() || /^about:blank(?:[?#]|$)/i.test(url.trim())
Ten filtr blokuje opisany przypadek, zanim otworzy się nowy ekran.
3. Logi: iframe staje się nawigacją aplikacji
Samo zabezpieczenie przed crashem nie wyjaśniało, skąd pochodził adres about:blank. Do prześledzenia tej ścieżki wykorzystałem logi z sandboxa odtwarzającego błąd bez zabezpieczeń.
native.navigation
request=about:blank
mainDocument=https://dewxit.github.io/new-relic-in-ios-webview-session-replay-issue/?replay=on
sourceMain=1 targetMain=0 hasTarget=1
Browser.navigation
isTopFrame=false url=about:blank decision=open-page
Page.source
url=about:blank
native.loadFileURL
url=about:blank isFileURL=0 readAccess=about:blank
Terminating app due to uncaught exception 'NSInvalidArgumentException'
reason: 'about:blank is not a file URL'
Patrząc na te logi, można wywnioskować, że:
native.navigation- główna strona ma poprawny HTTPS. Pojawia się nawigacja doabout:blank, której celem jest ramka podrzędna (targetMain=0).hasTarget = 1oznacza, że ramka docelowa istnieje i nie jest to samo w sobie żądanie nowego okna.Browser.navigation- ekran Home otrzymuje informacjęisTopFrame = false, czyli to nie jest nawigacja głównej strony.Page.source- nowy ekran przekazujeabout:blankjako adres do załadowania strony i próbuje renderować drugi WebView z tym adresem.native.loadFileURL- kod natywny próbuje załadować ten adres jako plik (co naprawiliśmy wyżej).Terminating app- WebKit zgłasza wyjątek i kończy proces aplikacji.
Za przejście od nawigacji ramki do otwarcia nowego ekranu odpowiadał poniższy warunek. param to parametr zapytania służący do rozpoznania bieżącej strony, a urlWithParam to jej adres z tym parametrem:
if (!request.url.includes(param) && !urlWithParam.includes(request.url)) {
setAlreadyNavigated(true)
navigation.navigate('Page', {
pageUrl: request.url,
})
return false
}
Handler decydował o otwarciu ekranu Page na podstawie adresu URL, bez sprawdzania request.isTopFrame. Dlatego żądanie about:blank pochodzące z ramki podrzędnej spełniało warunek otwarcia nowego ekranu. Aplikacja przekazywała ten adres jako pageUrl, a następnie jako źródło kolejnego WebView.
Fix zatrzymał puste adresy i about:blank przed wykonaniem navigate(). Usunął w ten sposób opisaną ścieżkę prowadzącą do crasha. Nie zmienił jednak ogólnej reguły obsługi ramek: adres HTTPS pochodzący z iframe nadal może otworzyć Page, jeśli spełni pozostałe warunki handlera.
4. Safari Web Inspector: łapiemy tworzenie niewidocznego iframe
W celu próby złapania tworzenia niewidocznej ramki ustawiłem breakpoint na createElement i appendChild, ale nie przyniosło to żadnej sensownej odpowiedzi. Aplikacja nadal się crashowała.
Za pomocą Inspector Bootstrap Script zaimplementowałem poniższy skrypt, targetujący prosto w node’y IFRAME:
(() => {
const original = Node.prototype.appendChild;
Node.prototype.appendChild = function (node) {
if (node?.nodeName === "IFRAME") {
debugger;
}
return original.call(this, node);
};
})();
Web Inspector musi być podłączony przed odtworzeniem problemu. Włączam skrypt i aktywuję breakpoint „Debugger Statements”. Dopiero wtedy przeładowuję stronę. Dzięki temu przechwytuję appendChild przed uruchomieniem kodu recordera.
Zatrzymanie następuje przed dodaniem ramki do DOM. W tym miejscu zapisuję call stack oraz atrybuty iframe. Po wznowieniu wykonania ramka może zostać dodana i natychmiast usunięta, dlatego późniejsze document.querySelectorAll("iframe") może zwrócić pustą listę.
Po wykonaniu powyższego skryptu call stack ukazał: startRecording → serializację DOM → kod New Relic SPA Recorder → przechwycone appendChild → debugger.
Po przeanalizowaniu, pasujące źródło:
if (!a) {
let t;
try {
(t = document.createElement("iframe")),
(t.hidden = !0),
document.body.appendChild(t);
const r = t.contentWindow;
if (r) {
const t = r[e].prototype;
t && (l = t);
}
} finally {
t && document.body.removeChild(t);
}
}
5. New Relic Session Replay i zagadka znikającej reprodukcji
New Relic działa jako skrypt monitorujący przeglądarkę, a stack zidentyfikował go jako skrypt tworzący tę konkretną ramkę. Pobiera on prototypy przeglądarek z obiektu contentWindow tymczasowej ramki iframe, a następnie usuwa tę ramkę w bloku finally. To wyjaśnia, dlaczego statyczny kod HTML ani późniejsze zapytanie do DOM nie pokazywały ramki iframe.
Kod źródłowy rejestratora znajdziesz tutaj (wersja z czasu opisywanego dochodzenia).
Po zablokowaniu skryptu New Relic Session Replay i reloadzie WebView strona załadowała się poprawnie, a crash nie wystąpił. Usunąłem więc blokadę, przeładowałem stronę i ta ku mojemu zdziwieniu nadal działała! Musiałem więc ustalić, czy kod podejrzany o tworzenie ramki został wykonany po usunięciu blokady. Po przeładowaniu nie widziałem pobrania skryptu recordera. Samo usunięcie blokady nie potwierdzało więc, że ponownie uruchomiłem ścieżkę prowadzącą do awarii.
To doprowadziło do kolejnego pytania: co decyduje o uruchomieniu rejestratora New Relic. Po analizie ustaliłem, że agent rozróżnia między dostępnością funkcji Session Replay i nagrywaniem dla konkretnej sesji, i korzysta ze stanu sesji. Sprawdziłem localStorage w Safari Web Inspector.
{
"sessionReplayMode": 0,
"sessionReplaySentFirstChunk": false,
"sessionTraceMode": 1,
"traceHarvestStarted": true,
"cachedRumResponse": {
"stn": 0,
"err": 1,
"ins": 1,
"spa": 1,
"sr": 1,
"srs": 0,
"st": 1,
"sts": 2,
"log": 2,
"logapi": 2
}
}
W działającej sesji znalazłem sessionReplayMode: 0, co oznacza OFF, oraz sessionReplaySentFirstChunk: false. W tym momencie nagrywanie nie było aktywne, mimo wyłączonej blokady wykonania.
Wartość sessionReplayMode: 1 włącza nagrywanie New Relic Session Replay. Gdy ustawiłem ją na 1 i przeładowałem stronę, crash wystąpił. Po kolejnej zmianie strona się załadowała. Wyzwalacz znajduje się w skrypcie wykonywanym przez stronę, a błędy zamieniające go w crash są w obsłudze nawigacji w aplikacji i w natywnym ładowaniu WebView.
Na tej podstawie byłem w stanie opisać pełny ciąg przyczynowo-skutkowy:
- API i dane używane przez Home zawierają poprawny adres HTTPS strony wydarzenia.
- Strona uruchamia agenta New Relic. Gdy dla sesji uruchamia się Session Replay, ładowany jest
nr-spa-recorder-1.323.0.min.js. - W zaobserwowanej ścieżce recorder tworzy ukryty iframe bez jawnie ustawionego
src. Korzysta z jegocontentWindow, aby uzyskać prototypy przeglądarki, po czym usuwa ramkę. - WebView zgłasza nawigację do
about:blankdla ramki podrzędnej. Główny dokument nadal ma poprawny adres HTTPS. - Handler Home otrzymuje
isTopFrame: false, ale mimo to wywołuje otwarcie Page z adresemabout:blank. - Nowy ekran otrzymuje
pageUrl: "about:blank"w parametrach nawigacji, a następnie przekazuje ten adres dosource.urikolejnego WebView. - Natywny kod wybiera sposób ładowania na podstawie obecności hosta w URL.
about:blanknie ma hosta, więc trafia do gałęziloadFileURL. about:blanknie jest adresem pliku. WebKit zgłasza wyjątek, którego aplikacja nie obsługuje, i proces zostaje zakończony.
Obecność agenta New Relic na stronie nie oznacza, że Session Replay nagrywa każdą sesję. New Relic obsługuje wybór sesji do nagrywania oraz nagrywanie związane z błędami. Na uchwyconej stronie widoczny był włączony autoStart, sampling_rate 1.0E-6 oraz error_sampling_rate 10.0. Reinstalacja mogła zmieniać stan sesji strony i wynik decyzji o nagrywaniu. Dwie sesje mogą dzięki temu zachować się zupełnie inaczej.
Docelowy fix powinien objąć obie warstwy: ignorować nawigacje ramek podrzędnych (isTopFrame: false), chyba że aplikacja ma je świadomie obsługiwać, oraz używać natywnego loadFileURL tylko dla adresów file:, zamiast wnioskować o typie adresu z braku hosta.
6. Podsumowanie
Najbardziej mylące było to, że każda warstwa oglądana osobno wyglądała niemal poprawnie: API zwracało prawidłowy HTTPS, strona działała w przeglądarce, a po reinstalacji problem potrafił zniknąć. Dopiero połączenie stosu JavaScript, zdarzeń nawigacyjnych WebView i logów natywnych pokazało pełną drogę do awarii.
New Relic dostarczał wyzwalacz: tymczasową ramkę. Aplikacja błędnie zamieniła jej nawigację w otwarcie kolejnego ekranu, a react-native-webview błędnie zakwalifikował adres jako plik. Dlatego w podobnym problemie warto logować nie tylko URL, ale również informację o ramce, decyzję handlera oraz źródło przekazane do nowego WebView. To właśnie na granicach między tymi warstwami zniknął kontekst, który decydował o poprawnej obsłudze żądania.






