Repository navigation
[Due for payment 2026-10-08] [Sentry: APP-M75 / APP-M74 / APP-M8W] Web Safari crash, Skia Host.dispose calls an undefined this[Symbol.dispose] #102043
Description
If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.
Version Number: 9.4.90
Reproducible in staging?: Needs to be confirmed
Reproducible in production?: Yes — crash is live in production
If this was caught during regression testing, add the test name, ID and link from BrowserStack: N/A — found via Sentry production crash scan
Email or phone of affected tester (no customers): N/A
Logs: https://stackoverflow.com/c/expensify/questions/4856
Expensify/Expensify Issue URL:
Issue reported by: Sentry production crash scan
Slack conversation (hyperlinked to channel name): N/A
Action Performed:
- Open New Expensify in Safari on macOS.
- Navigate to a page that renders a Skia canvas — the reported route is the Expensify Card details page at
/workspaces/:policyID/members/:accountID/card-details/:cardID. - Let the canvas draw a frame.
Expected Result:
The Skia frame draws, disposes its resources, and no error is thrown.
Actual Result:
Every drawn frame throws:
TypeError: this[Symbol.dispose] is not a function.
(In 'this[Symbol.dispose]()', 'this[Symbol.dispose]' is undefined)
The failure happens in the Skia teardown path, so it repeats on each frame. The event-to-user ratio is very high: 769 events across 23 users.
Workaround:
Unknown. Using Chrome instead of Safari avoids the reported events.
Platforms:
Select the officially supported platforms where the issue was reproduced:
- Android: App
- Android: mWeb Chrome
- iOS: App
- iOS: mWeb Safari
- iOS: mWeb Chrome
- Windows: Chrome
- MacOS: Chrome / Safari
Screenshots/Videos
Sentry details
Sentry issues:
- https://expensify.sentry.io/issues/APP-M75 — 11 users, 361 events
- https://expensify.sentry.io/issues/APP-M74 — 11 users, 363 events
- https://expensify.sentry.io/issues/APP-M8W — 1 user, 45 events
Impact (snapshot at filing, 2026-09-23):
- Users (last 7d, all three IDs): 23
- Events (last 7d, all three IDs): 769
- First seen: 2026-09-21
- Last seen: ongoing
- Platform: web, Safari 18.6 on macOS
- Affected routes (parameterized):
/workspaces/:policyID/members/:accountID/card-details/:cardID,/home - Mechanism:
auto.browser.global_handlers.onerror - Substatus: escalating
Stack trace (innermost last):
react-native-worklets/lib/module/threads.js:50:13
react-native-worklets/lib/module/threads.js:51:24 const result = workletFunction(...workletArgs)
@shopify/react-native-skia/lib/module/sksg/Container.web.js:113:7 drawOnscreen(Skia, nativeId, recording)
@shopify/react-native-skia/lib/module/sksg/Container.web.js:35:9 ctx.dispose()
@shopify/react-native-skia/lib/module/sksg/Recorder/FrameScope.js:49:22 disposables[i].dispose()
@shopify/react-native-skia/lib/module/skia/web/Host.js:25:25 this[Symbol.dispose]()
Suspected cause: Host.js implements dispose() by delegating to this[Symbol.dispose](), and on these clients the instance has no Symbol.dispose method. Two things are worth checking:
- Whether the Safari build in use supports explicit resource management well enough for the
Symbol.disposewell-known symbol to be present, and whether a polyfill is needed before Skia loads. - Whether the disposable object reaching
FrameScopeis one that never defined[Symbol.dispose], which would make this a Skia bug rather than a browser support gap.
Because the throw is in the per-frame teardown loop, a guard at FrameScope.js:49 that skips entries without a callable disposer would stop the bleeding while the real cause is found.
Related: filed alongside the CanvasKit wasm link failure found in the same scan. Both are @shopify/react-native-skia web issues that appeared on 2026-09-21, so a recent Skia or canvaskit-wasm bump is a good place to start for both.
Issue Owner
Current Issue Owner: @mallenexpensifyMetadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
melvin-bot commentedon Sep 23, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @cretadn22 (
External)melvin-bot commentedon Sep 23, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
mountiny commentedon Sep 23, 2026
@WojtekBoman can you take a look I think this might have become more frequent after the bump #100953
MelvinBot commentedon Sep 23, 2026
Proposal
What is the root cause of that problem?
Skia 2.11.x implements disposal on web as a delegation through the
Symbol.disposewell-known symbol:Host.js:24-27(as shipped innode_modules/@shopify/react-native-skia/lib/module/skia/web/Host.js)Safari does not ship
Symbol.dispose. MDN's browser-compat-data recordschrome: 125,firefox: 141,safari: "preview"— Explicit Resource Management is still Technology Preview only (WebKit STP 250 notes). So on Safari 18.6,Symbol.disposeisundefined.That fact alone is survivable, and this is the subtle part: a computed key runs through
ToPropertyKey, so[Symbol.dispose]() {}in the class body andthis[Symbol.dispose]at the call site both coerceundefinedto the string key"undefined"— they still match.It only breaks when the two evaluations disagree. The class key is computed once, when
Host.jsis evaluated; the call site re-readsSymbol.disposeon every call. If anything defines the real symbol after Skia's module has loaded, the method is stranded under the string key"undefined"whiledispose()now looks up a genuine symbol —undefined— and throws. This is exactly why the crash is Safari-only: on Chrome/Firefox the symbol is native from the very start, so the keys can never diverge.New Expensify loads several third-party scripts asynchronously after the bundle starts (
web/index.html:39-46,159-164— Ketch, Convert, GTM), plus the Group-IB SDK. Any vendor bundle built withcore-jsat theesnextlevel installsSymbol.disposeglobally on load. That is the most likely late definer, though I could not confirm which one without a Safari session.Why it repeats every frame, and why the card-details URL is in the report
Per-frame loop.
drawOnscreendisposes frame-scoped objects in afinallyblock on every draw (sksg/Container.web.js:31-37), which walksdisposables[i].dispose()insksg/Recorder/FrameScope.js:47-51, hittingHost.js:25for each one. That accounts for the 769 events / 23 users ratio.The routes.
/homereally does mount a Skia surface:HomePage.tsx:100→InsightsSectionContent.tsx:108→SearchChartView.tsx:45-47→SkiaWebChart/index.tsx:86.The card-details page mounts no Skia at all — I read
DynamicWorkspaceExpensifyCardDetailsPage.tsxin full; the card artwork is a static SVG. Covered screens stay mounted under<Activity mode="hidden">(ScreenActivityWrapper/index.tsx:78-83), so the Home chart keeps drawing behind the RHP and Sentry attributes the throw to whatever URL is current.The existing guard does not cover this.
useIsSkiaSurfaceUnavailableonly fires when a surface cannot be created. This crash happens after a surface exists, during teardown, so it is structurally blind to it.Regression window — pinned to a specific PR and deploy
Bump @shopify/react-native-skia and drop the patch (
Expensify/App#100953, merged 2026-09-18) moved@shopify/react-native-skiafrom^2.4.18to^2.11.2.The throwing code is new in 2.11.x —
FrameScope, the per-framectx.dispose(), and theHost.dispose()delegation all arrived upstream in fix(🐛): fix WASM memory consumption on RN Web reconciler (2026-07-13). 2.4.18 had no per-frame disposable loop.Deploy correlation:
9.4.89-0(2026-09-18T19:57Z) does not contain the merge commit;9.4.90-2(2026-09-21T18:37Z) does. That is the Sentry first-seen date and the reported version, exactly.Note the patch that PR dropped —
2.4.18+003+fix-dispose-symbol-eval.patch— is a red herring despite its name: it patched native C++/JSI (cpp/jsi/JsiHostObject.cpp) and cannot affect web Safari.What changes do you think we should make in order to solve the problem?
Define
Symbol.disposebefore anything can load Skia, and make it unreplaceable.The repo already has the right seam:
src/polyfills/, imported first inindex.js:4-7above every other import. Add aSymbolDisposepolyfill there, following the shape ofPromiseWithResolvers.ts: ifSymbol.disposeis missing, install one viaObject.defineProperty. Do the same forSymbol.asyncDispose.Two details matter:
./src/Appso it runs ahead of Skia's module evaluation. Then the class key and every call site agree for the rest of the session.writable: false, configurable: false, unlike the existing polyfills. A plain??=still leaves a later vendor script free to blind-assign over the symbol and recreate the divergence. Locking it is what actually closes the bug.One risk worth stating: a third-party script that blind-assigns
Symbol.disposein strict mode would now throw inside its own code.core-jsguards with a presence check, so it is unaffected.Also worth raising upstream (a one-line inversion, not required for the App fix):
Host.tsshould put the implementation ondispose()— a plain string key that always resolves — and have[Symbol.dispose]()call it. Same forJsiSkFontMgr.tsandJsiVideo.ts. That is immune toSymbol.disposechanging value at any time and keepsusingworking.What alternative solutions did you explore? (Optional)
FrameScope.js:49to skip entries without a callable disposer (suggested in the issue body). It stops the throw but silently skips disposal, reintroducing the exact WASM leak that fix(🐛): fix WASM memory consumption on RN Web reconciler wcandillon/react-native-skia#3940 was written to fix. Not recommended on its own.dispose() { this[Symbol.dispose](); }pair is still present on upstreammain.Expensify/App#100953. This would also resolve the sibling [Sentry: APP-M6B / APP-M76 / APP-M73 / APP-M7F] Web CanvasKit wasm fails to instantiate, Skia rendering crashes, which traces to the same PR via the unhashed/canvaskit.wasmURL meeting new 0.41.0 glue. Heavier than the polyfill, but it is the single lever that clears both.Related issues
Confidence and what I could not verify
Medium-high. Verified: the Safari support gap (MDN BCD), the throwing code and per-frame loop (read in
node_modules), the Skia surface on/home, and the regression pinned toExpensify/App#100953reaching production in 9.4.90 on the first-seen date.Not verified: which script installs
Symbol.disposelate. I ruled out everything innode_modulesthat could reach the production bundle —core-jsruntime is not installed, and@swc/helpers,tslib,canvaskit-wasm,@sentry/*,react-native-workletsand Skia itself only read the symbol, never assign it. The remaining candidates are the runtime-injected CDN scripts, whose contents I cannot inspect statically.I had no browser available this run, so nothing below is browser-confirmed. One console check on a Safari 18.6 machine on
/homesettles it: logtypeof Symbol.disposeright after load and again a few seconds later. If it flipsundefined→symbol, the mechanism is confirmed, andObject.getOwnPropertyNames(Object.getPrototypeOf(skiaObj))will show a literal"undefined"key. Blocking the CDN scripts one at a time in devtools then identifies the definer.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/102043)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run
cretadn22 commentedon Sep 28, 2026
@staszekscp I noticed you opened #102138. Could you check this issue to see if your PR also resolves this one?
melvin-bot commentedon Sep 28, 2026
@WojtekBoman Huh... This is 4 days overdue. Who can take care of this?
15 remaining items
melvin-bot commentedon Oct 7, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)melvin-bot commentedon Oct 7, 2026
Payment Summary
Resolving PRs:
Lock Symbol.dispose on web to fix Skia dispose errors in Safari #102521
Contributor: @WojtekBoman does not require payment (Contractor)
Reviewer: @cretadn22 owed $175 via NewDot
BugZero Checklist (@mallenexpensify)
melvin-bot commentedon Oct 12, 2026
@mallenexpensify Huh... This is 4 days overdue. Who can take care of this?
mallenexpensify commentedon Oct 14, 2026
Payment Summary
Contributor+: @cretadn22 due $175 via NewDot