Skip to content

Angular / Template / Material / 3rd-party Audit — non-Chromium Browser Compatibility

Temporary audit document — supports the planning report at ../non-chromium-browser-compatibility-analysis.md. Delete once findings are tracked in tickets.

Summary

  • Total findings: 18
  • Severity breakdown: critical 3 / major 8 / minor 7
  • Browsers affected: Safari 14 / Firefox 4 / both 12

Findings

F1. Auth0 silent logout iframe — broken under Safari ITP and Firefox ETP

  • File: src/app/core/auth/auth.effects.ts:600-619
  • Browsers: Safari, Firefox
  • Severity: critical
  • Symptom: Silent sign-out (silentSignOut$) creates a hidden iframe pointing at https://${auth0CustomDomain}/v2/logout?client_id=${clientId} to clear the IdP session without redirecting the user. In Safari with Intelligent Tracking Prevention (ITP) the third-party iframe cookie context is partitioned, so the request runs but the IdP session cookie isn't actually invalidated; the user appears signed out locally yet still has an active IdP session. Firefox Total Cookie Protection / ETP behaves similarly.
  • Cause: Hidden third-party iframe pattern + cross-site cookies; Safari ITP blocks cookies on third-party iframes; Firefox ETP partitions storage by top-level site.
  • Fix: Use the Auth0 SDK's logout({ federated: false, openUrl }) and either (a) accept a top-level redirect for sign-out, or (b) move the logout endpoint to a same-site sub-domain (e.g. auth.syrf.org.uk shares an eTLD+1 with the app). Long-term: BFF mode (bff-auth.provider.ts) avoids the iframe entirely — push auth0-mode users onto BFF.
  • Effort: medium

F2. Auth0 cacheLocation: 'localstorage' + useRefreshTokens: true — Safari Private Mode storage failures

  • File: src/main.ts:404-406
  • Browsers: Safari (Private Browsing in particular), Firefox (Private)
  • Severity: major
  • Symptom: In Safari Private Browsing, localStorage quota is 0 in some configurations and writes throw QuotaExceededError. Auth0 SDK swallows this but the refresh token is never persisted, forcing a fresh login on every page load and every silent token request.
  • Cause: @auth0/auth0-spa-js cacheLocation: 'localstorage' (default is memory). Combined with useRefreshTokens: true and useRefreshTokensFallback: true, the fallback (silent auth via iframe) is itself blocked by ITP — so when the localStorage cache fails, the fallback also fails, leading to repeated forced logins.
  • Fix: Switch to cacheLocation: 'memory' and rely on refresh tokens being kept in the auth state; or feature-detect Private Mode and fall back gracefully. Better: prefer BFF auth provider where token storage is server-side.
  • Effort: low

F3. SignalR — no explicit transport list; auto-falls-back to LongPolling under Safari edge cases

  • File: src/app/core/services/signal-r/signal-r.service.ts:189-199
  • Browsers: Safari, Firefox (cross-site WebSocket subtleties)
  • Severity: major
  • Symptom: HubConnectionBuilder().withAutomaticReconnect().withUrl(url, opts).build() does not pin transport: HttpTransportType.WebSockets. When the WebSocket upgrade fails (Safari ITP partitioning of cross-site cookies during the negotiate request, corporate proxies, or older Safari versions), SignalR silently falls back to ServerSentEvents and then LongPolling. LongPolling for SignalR is heavy and tends to break behind some Safari configurations because POSTs interleaved with the negotiate cookie can be cached differently. In BFF mode (cookie auth) Safari may strip the session cookie on the WS upgrade because the upgrade is a cross-site GET.
  • Cause: SignalR auto-negotiation + browser-specific cookie / WS handling. accessTokenFactory puts the bearer in ?access_token=… (because browsers don't allow Authorization headers on WebSocket); fine for Auth0 mode but exposes token in URL-logging contexts.
  • Fix: Explicitly pass { transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling, skipNegotiation: false }, log transport actually used (hubConnection.connectionId + a one-time event), and emit a Sentry breadcrumb on transport fallback so we can detect Safari-only fallback rates. Verify SignalR cookie path is same-site or use a sub-domain.
  • Effort: low

F4. Hidden file input pattern with programmatic click() — iOS Safari quirk

  • File: src/app/stage/stage-admin/review-data-upload/review-data-upload.component.html:84-93 and src/app/stage/stage-admin/review-data-upload/search-decision-upload/search-decision-upload.component.html:108-120
  • Browsers: Safari (iOS 14+ tightened user-gesture requirements), Firefox
  • Severity: major
  • Symptom: "Browse File" button calls csvInput.click() on an <input type="file" fxHide> that is rendered with FlexLayout's fxHide (CSS display: none). iOS Safari has historically refused to open the file picker for hidden file inputs (at minimum needs visibility: hidden + position: absolute + opacity: 0 rather than display: none). Some older Firefox versions also won't dispatch the picker on a display: none input.
  • Cause: fxHide from @ngbracket/ngx-layout applies display: none !important. Apple's WebKit refuses to dispatch native file pickers from elements that are not in the rendering tree.
  • Fix: Use a CSS sr-only / visually-hidden pattern instead of fxHide: position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;. Or use a <label for="…">-wrapped button so the click is a native label click (most reliable across browsers).
  • Effort: trivial

F5. File input accept attribute mixes MIME types and dotted extensions inconsistently

  • File: src/app/project/project-overview/create-search/create-search.component.ts:336-340, src/app/project/project-overview/update-studies/update-studies.component.ts:288-289
  • Browsers: Safari, Firefox
  • Severity: minor
  • Symptom: allowedExtensions = 'text/plain, text/csv, text/tab-separated-values, .tsv, .csv' and 'Application/xml' (capital A). Safari historically ignored unknown MIME entries and only honoured dotted extensions; Firefox accepts both but is case-sensitive on the MIME portion. Capital-A 'Application/xml' is technically invalid per RFC 7231 (MIME types are case-insensitive in practice but Firefox file-picker filtering has been observed to mismatch). Result: Safari users may see "All files" instead of the filtered picker; Firefox may show empty filter.
  • Cause: Inconsistent accept value generation.
  • Fix: Use lowercase MIME types and prefer dotted extensions (.csv,.tsv,.txt,.xml). Material/Angular has no validation here; just normalise the strings.
  • Effort: trivial

F6. Material datepicker uses date-fns adapter with navigator.language — locale parsing inconsistency

  • File: src/app/info/contact-us/contact-us.component.ts:286-292, src/app/project/project-admin/risk-of-bias-job-table/risk-of-bias-job-table.component.ts:47-51, src/app/core/dynamic-locale.ts:121-210
  • Browsers: Safari, Firefox
  • Severity: major
  • Symptom: MAT_DATE_LOCALE set to navigator.language. Safari returns territory-less codes (en instead of en-GB) more often than Chromium and the territory-less dynamic-locale loader (dynamic-locale.ts:65) falls through to a default; users in en-GB get US date order (MM/dd/yyyy) parsing in the input field, leading to silent off-by-month/day date submissions.
  • Cause: navigator.language differs by browser; Safari especially has been known to omit the region. The custom DynamicLocaleDateAdapter falls through to enUS if the locale code isn't found.
  • Fix: Detect region from navigator.languages[0] then fall back to Intl.DateTimeFormat().resolvedOptions().locale. Add a debug log that warns when the adapter falls back to enUS. Long-term, prefer ISO format input (yyyy-MM-dd) for raw submissions.
  • Effort: low

F7. image-cropper (ngx-image-cropper@6.3.3) — Safari iOS image EXIF/orientation handling

  • File: src/app/manage/profile/image-cropper-dialog/image-cropper-dialog.component.html:1-12
  • Browsers: Safari (especially iOS)
  • Severity: major
  • Symptom: Profile-picture cropper. iOS Safari's <img>/Canvas pipeline may render images sideways when EXIF orientation is non-default (front-camera selfies). ngx-image-cropper@6 added an imageQuality and limited EXIF support but historic versions of WebKit ignored image-orientation: from-image and the canvas didn't apply EXIF.
  • Cause: Safari/WebKit's historical refusal to honour EXIF in canvas drawImage. ngx-image-cropper versions <6.4 had bugs around orientation when reading via FileReader on iOS.
  • Fix: Bump to latest ngx-image-cropper (>=7.x) which auto-handles EXIF via createImageBitmap({ imageOrientation: 'from-image' }), and add CSS image-orientation: from-image on the <image-cropper> host. Test with iPhone selfies in QA.
  • Effort: low

F8. Handsontable — clipboard / contextMenu interactions on Safari

  • File: src/app/shared/form-controls/timepoint-spreadsheet/timepoint-spreadsheet.component.ts:132-147, src/app/shared/annotation/annotation-form/annotation-experiment-question/outcome-table-info/outcome-table-info.component.ts:13-28 (handsontable@^14.4.0)
  • Browsers: Safari, Firefox
  • Severity: major
  • Symptom: copyPaste: true enables Handsontable's clipboard subsystem. Handsontable historically (and through 14.x) has had cross-browser issues:
  • Safari refuses document.execCommand('copy') outside of a synchronous user-gesture stack — Handsontable's contextMenu copy/paste path is synchronous but execCommand is deprecated; on Safari TP and recent macOS Safari, it fails silently.
  • Firefox blocks navigator.clipboard.writeText outside secure contexts and outside user-gesture; pasting large grids may truncate.
  • Context menu (contextMenu: { items: { row_above, row_below, remove_row } }) intercepts the OS-level menu and Safari users still get the OS menu on top in some configurations (a known WebKit bug with contextmenu event).
  • Cause: handsontable@^14.4.0 clipboard implementation; reliance on legacy execCommand.
  • Fix: Upgrade to handsontable@>=15 which uses navigator.clipboard API behind feature detection, and verify the outcomeTableInfoComponent lazy-load (ensureHotBasicsRegistered) does not fire while page is hidden (Safari throttles background tabs more aggressively). Add a Sentry breadcrumb on copy/paste failure.
  • Effort: medium

F9. Highcharts ESM master import — module resolution on Safari (legacy)

  • File: src/app/stage/stage-overview/time-progress/time-progress.component.ts:10, current-progress-pie/current-progress-pie.component.ts:11, member-time-progress/member-time-progress.component.ts:6
  • Browsers: Safari (versions <14)
  • Severity: minor
  • Symptom: import Highcharts from 'highcharts/es-modules/masters/highcharts.src.js' — bundler-resolved fine, but the .src.js master uses ES2018 features (private class fields in places). Safari <14 chokes on #privateField. Less an issue today (Safari 14+ is universal) but if you support older iPad/iOS in user base, this breaks rendering of progress charts.
  • Cause: Highcharts 11.x dropped IE/legacy Safari; ESM master assumes ES2020.
  • Fix: Confirm browserslist excludes Safari <14; the build's ngBuild transpilation should target ES2020+ (verify in tsconfig.build.json). If older Safari support is needed, switch to highcharts/highcharts.js (UMD, broader compat).
  • Effort: trivial

F10. PDF.js worker URL via import.meta.url — Safari worker module support

  • File: src/app/pdf-tools/pdf-loader.service.ts:7-12
  • Browsers: Safari (especially older iOS), Firefox (any version <=113 with module workers off)
  • Severity: major
  • Symptom: pdf.worker.min.mjs is loaded as an ES module worker via new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url). ES module workers (new Worker(url, { type: 'module' })) only landed in Safari 15 and Firefox 114. Earlier versions throw "TypeError: Module Worker not supported" and the PDF preview fails to load entirely.
  • Cause: PDF.js 4.x ships only the .mjs worker. Application code uses import.meta.url resolution.
  • Fix: Provide a non-module worker fallback (pdf.worker.min.js) and detect support. Many projects ship a small wrapper that detects 'serviceWorker' in navigator plus module worker support and falls back. Confirm pdfjs-dist 4.10.38 ships a non-module build under legacy/build/.
  • Effort: medium

F11. CDK Drag & Drop for question-tree designer — Safari touch/pointer event differences

  • File: src/app/project/project-admin/question-management/design/annotation-question-tree-drag-drop.feature.ts:140-310, design.component.html:57-200
  • Browsers: Safari (iPad/touch), Firefox (mobile)
  • Severity: major
  • Symptom: Custom HTML5 drag-and-drop using native (dragover), (dragleave), dataTransfer.dropEffect. iOS Safari does NOT fire native HTML5 drag events (only iPadOS 15+ on external pointer; touch never fires dragstart). The question-tree designer is fully broken on iPad without a Bluetooth mouse. Firefox has subtle differences with dragleave firing more often than expected.
  • Cause: HTML5 drag API was never reimplemented for touch in iOS Safari. Application chose native DnD over CDK's cdkDrag (which IS pointer-event based and works on touch).
  • Fix: Migrate the question-tree to @angular/cdk/drag-drop (cdkDropList, cdkDrag) which uses Pointer Events under the hood and works cross-browser including touch. The codebase already uses CDK DnD elsewhere (input-array.component.ts:38, graph-selector.component.ts:41).
  • Effort: high

F12. <input type="search"> in mat-form-field — Safari forced UA styling

  • File: src/app/auth/dev-login/dev-login.component.html:35-44, src/app/admin/impersonation/impersonation.component.html:42-50, src/app/project-index/project-index.component.html:28-38
  • Browsers: Safari
  • Severity: minor
  • Symptom: Safari's UA stylesheet for <input type="search"> enforces rounded corners, an internal "X" clear button, and the styled-search appearance even when the input is not in a mat-form-field. The first two examples (dev-login, impersonation) are bare <input type="search"> without a form field, so users see Safari's native "X" clear button alongside the custom one in the template — leading to double-X UX.
  • Cause: Safari's -webkit-appearance: searchfield cannot be fully reset without appearance: none + -webkit-appearance: none.
  • Fix: Add appearance: none; -webkit-appearance: none; to .search-field SCSS, or change the type to text (no semantic loss for a non-form search box).
  • Effort: trivial

F13. scrollIntoView({ behavior: 'smooth' }) — Safari support partial

  • File: src/app/project/project-admin/project-options/project-options.component.ts:231-235
  • Browsers: Safari (<15.4)
  • Severity: minor
  • Symptom: behavior: 'smooth' was unsupported on Safari until 15.4 (March 2022). Older iPad/iOS users see an instant jump instead of smooth scroll. Not a functional break but a UX regression.
  • Cause: Safari implementation lag.
  • Fix: Acceptable as graceful degradation (just snaps). If smooth is required on older Safari, polyfill with smoothscroll-polyfill or a custom requestAnimationFrame ease.
  • Effort: trivial

F14. @ngbracket/ngx-layout (fxLayout, fxHide, fxFlex) — old IE/Edge flex bugs leak into Safari

  • File: Many — e.g. src/app/shared/form-controls/timepoint-array/timepoint-array.component.html:7,17,26,49,55
  • Browsers: Safari (subtle), Firefox (subtle)
  • Severity: minor
  • Symptom: @ngbracket/ngx-layout@20.0.1 is a community fork of the abandoned @angular/flex-layout. It applies inline styles via Angular directives. Safari and Firefox handle flex-basis differently than Chromium when the parent has min-width: auto (default) — flex children may overflow. Visible in timepoint-array.component.html where 3 flex children with class="full-width" overlap or wrap unexpectedly on narrower viewports in Safari.
  • Cause: Flex-Layout-style libraries embed Chromium-tuned defaults; lack of min-width: 0 on flex children is a known cross-browser gotcha.
  • Fix: Add min-width: 0 to direct flex children; gradually migrate off ngx-layout to native CSS Grid / flex (Angular team officially deprecated @angular/flex-layout).
  • Effort: medium (across many components)

F15. Stray <script> reference in timepoint-array.component.html:1

  • File: src/app/shared/form-controls/timepoint-array/timepoint-array.component.html:1
  • Browsers: all (but cross-browser parsing differences expose it)
  • Severity: critical (security + correctness)
  • Symptom: <script src="../../../../../../../../../Desktop/main-es5.0d374fcad9b6d03574d7.js"></script> — an absolute-path-traversal <script> tag referencing a developer's Desktop. In production builds this will 404; in browsers' error logging Safari/Firefox may surface a CSP violation or strict-mixed-content warning that doesn't appear in Chromium dev tools the same way. It's also a sign of a broken dev artefact in source control.
  • Cause: Accidental commit of a debugging script tag.
  • Fix: Delete line 1 of timepoint-array.component.html. (Out-of-scope for this audit but flagged because it surfaces differently per browser.)
  • Effort: trivial

F16. PWA display: standalone in site.webmanifest — Safari ignores

  • File: src/site.webmanifest:18
  • Browsers: Safari
  • Severity: minor
  • Symptom: iOS Safari (until iOS 16.4) ignored Web Manifest entirely; even now it only honours a subset and prefers apple-mobile-web-app-capable <meta> for "Add to Home Screen". The display: standalone property is a no-op on iOS — users get a regular Safari tab when launching from home screen.
  • Cause: Apple's slow adoption of Web Manifest spec.
  • Fix: Add <meta name="apple-mobile-web-app-capable" content="yes"> and <meta name="apple-mobile-web-app-status-bar-style" content="default"> to index.html. Provide apple-touch-icon link tags (currently absent from index.html).
  • Effort: trivial

F17. localStorage for crash-recovery state — Safari Private Mode quota

  • File: src/app/app.component.ts:330,343,348,382,400,401,408, src/app/core/components/error-recovery-dialog/error-recovery-dialog.component.ts:28,32, src/app/core/auth/auth.effects.ts:640,699,721 (impersonation)
  • Browsers: Safari (Private Browsing), Firefox (Private)
  • Severity: major
  • Symptom: localStorage.setItem('app_recovered_from_crash', …) runs unconditionally; in Safari Private mode this throws QuotaExceededError. Some calls are wrapped in try/catch but the recovery UI relies on the value being read back, so users in Private mode never see crash-recovery prompts and impersonation state silently doesn't persist.
  • Cause: Direct localStorage access without availability detection.
  • Fix: Wrap in a safeStorage utility that does feature-detect (try { localStorage.setItem('__t','1'); localStorage.removeItem('__t'); return true } catch { return false }) and gracefully no-ops when storage isn't writable. Already exists pattern at version-info-dialog.component.ts:224 (clipboard) — replicate.
  • Effort: low
  • File: src/main.ts:401
  • Browsers: Safari, Firefox (ETP-strict)
  • Severity: major
  • Symptom: Auth0 Universal Login redirect-flow uses cookies on the auth0 domain. Safari ITP (iOS 13.4+) "redirect tracking" rules will purge cookies set by auth0.com after 7 days unless the user re-engages on that domain. Result: every ~7 days users get bounced to a fresh login. The useRefreshTokens: true mitigates ongoing API access, but the ID-token rotation flow uses iframes to re-establish session, and those iframes are subject to ITP partitioning (see F1, F2).
  • Cause: Apple's ITP & Safari partitioning of third-party storage; Auth0 default flow.
  • Fix: Configure auth0CustomDomain to be a sub-domain of syrf.org.uk (e.g. auth.syrf.org.uk) — this makes Auth0's cookies first-party and bypasses ITP/ETP. Audit infra to confirm the custom-domain CNAME is configured. Field name auth0CustomDomain in app-settings.interface.ts suggests this is intended; confirm staging/prod values.
  • Effort: low (config) / medium (infra)

Cross-cutting observations

  1. Auth iframe pattern is fragile across non-Chromium browsers. F1, F2, F18 all stem from the same root cause: Safari ITP and Firefox ETP increasingly punish hidden iframes / third-party cookies. The BFF auth provider (bff-auth.provider.ts) is the long-term answer; expedite migration off Auth0 SDK in browsers.

  2. Custom HTML5 drag-and-drop vs CDK DnD inconsistency. The codebase uses two drag systems: native HTML5 (design.component) and CDK (input-array, graph-selector). CDK works on touch; native HTML5 does not. F11 is the highest-impact UX issue for Safari/iPad users.

  3. Hidden inputs via display: none. Two file-upload components use fxHide (display:none) which iOS Safari historically refuses to dispatch native pickers from. Use sr-only patterns (F4).

  4. Storage failures are silently swallowed. Auth (localStorage), crash-recovery, and impersonation all assume localStorage works. Safari Private Browsing and aggressive ETP both break this; add a defensive wrapper (F17).

  5. Module Workers in PDF.js (F10). Worth verifying browser support telemetry — pdfjs-dist 4.10 dropped legacy worker. If you have any iOS 14 traffic, PDF preview is fully broken.

  6. No central polyfills strategy. polyfills.ts only imports zone.js. Verify the build's browserslist (root package.json or .browserslistrc) — there's no .browserslistrc visible in the web service, so Angular CLI defaults apply (> 0.5%, last 2 versions, Firefox ESR, not dead). That actually does include Safari 14+ but you may unknowingly support older Safari versions where ESM workers (F10) and PWA standalone (F16) fail.