Skip to content

JS / Web API 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.

Scope: TypeScript / JavaScript files under src/services/web/src/ (Angular 21 SPA). Searches did not turn up a projects/ directory; the workspace is a single Angular project. node_modules, dist, and .angular were excluded.

Summary

  • Total findings: 14
  • Severity breakdown: critical 2 / major 6 / minor 6
  • Browsers affected: Safari 11 / Firefox 11 / both 9

The most acute issue is study-table.effects.ts#downloadFile which calls IE-only msSaveOrOpenBlob as a function with no guard — every non-IE browser will throw a TypeError if this method runs (Safari and Firefox alike). Although the method does not appear to be invoked from current call-sites, leaving it on a hot service file is a footgun.

The second-most-acute is the HTML5 drag-and-drop in annotation-question-tree-drag-drop.feature.ts that never calls dataTransfer.setData() at dragstart. Firefox quietly fails to initiate the drag in this configuration.

There are also several uses of Chromium-only navigator.userAgentData and unawaited navigator.clipboard.writeText calls that fail silently outside secure contexts. A pdfjs-dist v4 worker is loaded as .mjs — its support is limited to recent Safari (16.4+) and Firefox (114+), users on older minor versions will see PDF rendering fail.

Findings

F1. msSaveOrOpenBlob invoked without IE feature-check (TypeError on Safari/Firefox/Chrome)

  • File: src/services/web/src/app/core/services/study-table/study-table.effects.ts:74-90
  • Browsers: Safari, Firefox, Chrome (any non-IE)
  • Severity: critical
  • Symptom: As soon as downloadFile() runs, the line if ((window.navigator as any).msSaveOrOpenBlob(blob, filename)) throws TypeError: window.navigator.msSaveOrOpenBlob is not a function because the method only exists in IE/legacy Edge. The else branch (the modern <a>.click() flow) is never reached.
  • Cause: Missing typeof === 'function' guard. Compare to the correct pattern in src/services/web/src/app/shared/utils/download-file.ts:6-9 which guards via typeof nav.msSaveOrOpenBlob === 'function' first.
  • Fix: Replace the body with a call to saveBlob() from @app/shared/utils/download-file, or add the same typeof guard. The method appears unused in the live codebase (no call sites found), so deleting it outright is also a valid option.
  • Effort: trivial

F2. Drag-and-drop dragstart never calls dataTransfer.setData() (Firefox)

  • File: src/services/web/src/app/project/project-admin/question-management/design/annotation-question-tree-drag-drop.feature.ts:509-527
  • Browsers: Firefox
  • Severity: critical
  • Symptom: Question-tree drag-and-drop reordering does not initiate, or drops are silently ignored, in Firefox.
  • Cause: Firefox's HTML5 drag-and-drop spec compliance is strict: a dragstart handler must call event.dataTransfer.setData(format, value) at least once or the drag is not registered. The handler only sets effectAllowed and a drag image.
  • Fix: Add dragEvent.dataTransfer!.setData('text/plain', draggedQuestionId) early in dragStart (before setDragImage). The value can be the question ID since that's what the drop handler resolves anyway.
  • Effort: trivial

F3. navigator.userAgentData is Chromium-only — no real fallback validation

  • File: src/services/web/src/app/core/services/client-environment/client-environment.service.ts:31-46
  • Browsers: Safari, Firefox
  • Severity: major
  • Symptom: "Detected browser/OS" fields shown in the Contact-Us form (contact-us.component.ts:341,650-654) and in the version-info dialog will fall back to UA-string parsing in Safari/Firefox. The legacy parser handles Safari but reports Chrome for Chromium-Edge first (because the regex order in parseLegacyUA is ChromeEdg); Edge UAs always contain Chrome/… so Edge is mis-detected (low impact). The Promise chain itself does not break.
  • Cause: nav.userAgentData?.getHighEntropyValues only exists in Chromium. The fallback works but mislabels Edge.
  • Fix: Reorder the pick calls in parseLegacyUA so Edg/, OPR/, then Firefox/, then Safari, then Chrome are tested in priority order. Optionally add userAgentData type guard ('userAgentData' in navigator) to silence type warnings.
  • Effort: low

F4. PDF.js v4 worker as .mjs requires Safari 16.4+ / Firefox 114+

  • File: src/services/web/src/app/pdf-tools/pdf-loader.service.ts:5-13
  • Browsers: Safari < 16.4, Firefox < 114 (module workers)
  • Severity: major
  • Symptom: PDF rendering in pdf-display, pdf-page, and graph-selector components silently produces a blank canvas / never resolves on iOS Safari 15.x, macOS Safari 15.x, and Firefox ESR <114. The underlying error is SyntaxError: import.meta.url or DOMException: failed to construct module Worker.
  • Cause: pdfjs-dist@^4.10.38 ships its worker as an ES module (pdf.worker.min.mjs). Module workers are gated behind Safari 16.4 (March 2023) and Firefox 114 (June 2023). pdfjs-dist v4 also relies on Promise.withResolvers and Array.prototype.at, both shipped only in Safari 16+/Firefox 113+.
  • Fix: Either (a) downgrade to pdfjs-dist@3.x which ships a classic worker, (b) use the legacy build bundled with v4 (pdfjs-dist/legacy/build/pdf.worker.min.js), or © document the Safari 16.4+ / Firefox 114+ floor explicitly and bail out with a friendly message for older browsers. Option (b) is the smallest diff: pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/legacy/build/pdf.worker.min.js', import.meta.url).toString().
  • Effort: medium

F5. crypto.subtle.digest requires a secure context

  • File: src/services/web/src/app/core/services/s3-file.service.ts:237-245
  • Browsers: Safari, Firefox (and Chrome) when on plain HTTP
  • Severity: major
  • Symptom: Search-import file uploads fail silently — generateSha256FileHash() rejects with TypeError: undefined is not an object (evaluating 'crypto.subtle.digest') on Safari, or TypeError: crypto.subtle is undefined on Firefox, when the page is served over http:// (only localhost is whitelisted as secure insecurely). All three SyRF environments are HTTPS, but PR-preview URLs and any future internal staging served over plain HTTP would trip this.
  • Cause: crypto.subtle is restricted to secure contexts in all browsers, but Safari and Firefox enforce this slightly more strictly (Chrome occasionally permits localhost / 127.0.0.1 / file:// in dev). No fallback or guard is present.
  • Fix: Wrap with if (!window.isSecureContext) throw new Error('File hash requires HTTPS — please access via the secure URL'), surface the error in the upload UI, and document the HTTPS requirement.
  • Effort: low

F6. Multiple unawaited / unhandled navigator.clipboard.writeText calls

  • Files:
  • src/services/web/src/app/manage/profile/profile.component.ts:264
  • src/services/web/src/app/project/project-overview/project-overview.component.ts:175
  • src/services/web/src/app/studies/study-table/study-table.component.ts:732
  • src/services/web/src/app/project/project-admin/systematic-searches/systematic-searches.component.ts:156
  • Browsers: Safari, Firefox (failure modes differ)
  • Severity: major
  • Symptom: "Copied to clipboard" snackbars appear even when the underlying copy failed. Failure modes:
  • Safari (esp. iOS) requires the call to happen synchronously inside a user-gesture handler. RxJS / NgRx async pipelines that defer the call by a microtask trip NotAllowedError. None of these call sites awaits or catches the returned Promise, so the snackbar lies.
  • Firefox blocks clipboard.writeText outside of secure contexts and surfaces a rejection that is silently swallowed.
  • Cause: Each call site fires-and-forgets the Promise without .catch() or await plus a fallback (e.g. the <textarea> + execCommand('copy') fallback already implemented in version-check-dialog.component.ts:56-74 and export-error-details-dialog.component.ts:261-279).
  • Fix: Refactor copy-to-clipboard into a shared service (ClipboardService) that:
  • Tries navigator.clipboard.writeText if navigator.clipboard exists.
  • Falls back to the document.execCommand('copy') textarea trick.
  • Returns Promise<boolean> so callers know whether to show success or failure snackbar.
  • Effort: medium

F7. Date.parse(...) on user-supplied/audit timestamp strings

  • Files:
  • src/services/web/src/app/core/state/entities/invitation/invitation.selectors.ts:74
  • src/services/web/src/app/core/state/entities/membership/membership.selectors.ts:68
  • src/services/web/src/app/core/state/entities/stage-usage/stage-usage.selectors.ts:22-23,52-53
  • src/services/web/src/app/core/state/entities/project-usage/project-usage.selectors.ts:18-19
  • src/services/web/src/app/core/state/entities/join-request/join-request.selectors.ts:62
  • src/services/web/src/app/core/state/entities/project/project-detail.viewmodel.ts:71
  • Browsers: Safari (and historically Firefox)
  • Severity: minor (assuming server returns ISO-8601)
  • Symptom: Invitation, membership, stage-usage, etc. lists sort inconsistently (some entries appear "at the start of time" because Date.parse returned NaN).
  • Cause: The server emits ISO-8601 with T separator, but if any timestamp is ever serialised in a different format (e.g. "2026-05-01 12:34:56"), Safari rejects it (returns NaN) while Chrome accepts. The selectors do not guard against NaN.
  • Fix: Use new Date(timestamp).valueOf() and isNaN-check the result, or use the project's date-fns (already imported in dynamic-locale.ts) via parseISO. Add explicit defaults for invalid dates.
  • Effort: low

F8. (window.location.reload as any)(true) — non-standard signature

  • File: src/services/web/src/app/core/services/signal-r/signal-r.service.ts:289
  • Browsers: Firefox (since 28), Safari ignores or warns
  • Severity: minor
  • Symptom: When SignalR receives a UiVersionCheck for an upgrade, the page reload fires the deprecated boolean forceReload argument. Firefox emits a console warning ("forceReload is no longer supported"). The reload itself still happens because the boolean is silently ignored.
  • Cause: Location.reload() no longer accepts an argument since the spec was tightened ~2014. The cast to any was added to suppress the TS error.
  • Fix: Replace with window.location.reload(). To force-bypass-cache, prefer adding a cache-busting query param to a navigation, or rely on service-worker / HTTP caching headers.
  • Effort: trivial

F9. Top-level-await IIFE in main.ts requires ES2022 target everywhere

  • File: src/services/web/src/main.ts:153-490
  • Browsers: Safari < 15, Firefox < 89
  • Severity: minor (Angular CLI normally transpiles)
  • Symptom: If the build pipeline ever fell back to a lower target, Safari 14.x and Firefox <89 would refuse to parse the bundle (SyntaxError: unexpected token 'await').
  • Cause: tsconfig.json:41 sets "target": "ES2022". Angular's bundler usually downlevels to ES2017, but if a custom webpack rule (the workspace has custom-webpack.config.js) leaks the ES2022 target into the production bundle, async-IIFE inside a non-async wrapper is the failure point.
  • Fix: Verify angular.json's production build target / browserslist output. Add > 0.5%, last 2 versions, Firefox ESR, not dead, not IE 11 if no .browserslistrc file is present (none was found). The standard async IIFE form (async () => {...})() is fine for ES2017+.
  • Effort: low (verify only)

F10. document.execCommand('copy') deprecation

  • Files:
  • src/services/web/src/app/project/data-export/shared/export-error-details-dialog/export-error-details-dialog.component.ts:272
  • src/services/web/src/app/core/components/version-check-dialog/version-check-dialog.component.ts:67
  • Browsers: Safari, Firefox (warning only today)
  • Severity: minor
  • Symptom: Console warnings; execCommand('copy') still works as a fallback today but is deprecated. Firefox 127+ emits a DevTools deprecation warning. Safari 17.4+ has begun returning false on cross-origin iframes.
  • Cause: Used as fallback when navigator.clipboard is unavailable.
  • Fix: Keep as last-resort fallback for now, but track the deprecation. Once F6 is consolidated into a ClipboardService, the fallback lives in exactly one place where it is easy to swap when browsers drop support.
  • Effort: trivial

F11. ResizeObserver — no polyfill or feature-check

  • Files:
  • src/services/web/src/app/project/project.component.ts:42,56
  • src/services/web/src/app/core/services/layout/layout.service.ts:32-84
  • Browsers: Safari < 13.1, Firefox < 69
  • Severity: minor (modern Safari/Firefox fine)
  • Symptom: On older iOS Safari (< 13.1) the constructor throws ReferenceError: ResizeObserver is not defined, breaking the project shell layout.
  • Cause: No typeof ResizeObserver === 'undefined' guard.
  • Fix: Wrap construction in if (typeof ResizeObserver !== 'undefined') { … }. If the project already drops support for Safari < 14 in package.json/CI, document that in polyfills.ts:11 (the comment still says "Safari >= 10").
  • Effort: trivial

F12. polyfills.ts comment is stale and contains no actual polyfills

  • File: src/services/web/src/polyfills.ts:1-58
  • Browsers: all
  • Severity: minor
  • Symptom: Documentation drift — the file claims to support "Safari >= 10, Chrome >= 55, Edge >= 13" but no compatibility shims are imported. Maintainers may believe older browsers are still tested.
  • Cause: Boilerplate left over from Angular CLI scaffolding.
  • Fix: Either add the polyfills for the documented baseline (or, more practically, shrink the support matrix in the comment to match modern Angular 21 baseline: Chrome ≥120, Edge ≥120, Safari ≥17, Firefox ≥130 — see Angular 21 release notes). Pair with adding a .browserslistrc file in the web service root.
  • Effort: low

F13. tsconfig.json declares "types": ["user-agent-data-types"]

  • File: src/services/web/tsconfig.json:26-29
  • Browsers: Safari, Firefox
  • Severity: minor
  • Symptom: Encourages further use of Chromium-only navigator.userAgentData because the type is globally available. Already noted at F3.
  • Cause: The type package was added when client-environment.service.ts was first written. Removing it forces a code review when developers reach for the API.
  • Fix: Either remove the global types entry and import the types locally in client-environment.service.ts, or leave it but add an ESLint rule banning navigator.userAgentData outside that one file.
  • Effort: trivial

F14. PDF rendering uses import.meta.url which is ES Modules-only

  • File: src/services/web/src/app/pdf-tools/pdf-loader.service.ts:8-12
  • Browsers: Safari < 12.1, Firefox < 60
  • Severity: minor (related to F4)
  • Symptom: Any browser that does not support import.meta cannot resolve the worker URL. In practice this overlaps with F4 — anything modern enough to run pdfjs-dist@4 supports import.meta.url.
  • Cause: import.meta.url is an ES Modules feature. Angular CLI's application builder emits ES modules in the production bundle, so end-users on Safari 12 / Firefox 59 cannot run the app at all.
  • Fix: Once F4 is fixed (legacy worker), remove import.meta.url and point at the static asset path served by Angular (e.g. '/assets/pdf.worker.min.js').
  • Effort: low (fix together with F4)

Patterns observed

  • Inconsistent clipboard handling: 8 distinct call sites use navigator.clipboard.writeText, only 2 of them have a fallback path, and none of them shares code. Consolidating into a single ClipboardService resolves F6 + F10 in one go.
  • Stale IE compatibility shims: msSaveOrOpenBlob references in two places, only one with proper feature-detection. Modern browsers do not need this fallback and the broken site (F1) actively crashes outside IE.
  • Chromium-first feature use without explicit polyfills: navigator.userAgentData (F3), pdfjs-dist v4 module worker (F4 / F14), crypto.subtle (F5) — together these establish a soft floor of "modern Chrome" without surfacing requirements to users on Safari 15.x or Firefox ESR.
  • Missing .browserslistrc: The repository root has no browserslist configuration. Adding one would make the support matrix explicit and let Angular's bundler decide on transpilation.
  • No SignalR transport forcing: Good news — signal-r.service.ts:196-199 uses default transport selection, so SignalR negotiates WebSockets first then falls back to ServerSentEvents and LongPolling automatically. Safari and Firefox both speak WebSockets, so no transport-related bug exists. (No finding required, noted for completeness.)

What was NOT found (negative findings)

For the record, the following Chrome-only APIs were searched for and did not appear in the codebase:

  • Element.prototype.scrollIntoViewIfNeeded — only standard scrollIntoView used (project-options.component.ts:232).
  • HTMLInputElement.showPicker(), showOpenFilePicker, showSaveFilePicker, showDirectoryPicker — none.
  • navigator.share, EyeDropper, URLPattern, navigator.locks — none.
  • webkitAudioContext, webkitFullscreen* vendor prefixes — none.
  • BroadcastChannel, OffscreenCanvas, requestIdleCallback — none.
  • IntersectionObserver — none in app code (only in test setup).
  • Service worker / Web Workers / SharedWorker (other than pdfjs) — none.
  • Indexed DB direct usage — none.
  • Regex /.../d indices flag, lookbehind assertions, named-capture groups — none.
  • Array.prototype.at, findLast, toSorted, toReversed, Object.hasOwn, structuredClone — none.
  • MediaRecorder, AudioContext, fullscreen APIs — none.
  • HTML <dialog> element — only Material's MatDialog is used.