fix(window): guard EventEmitter map lookups against missing keys - #2010
Open
AngelPaella wants to merge 5 commits into
Open
fix(window): guard EventEmitter map lookups against missing keys#2010AngelPaella wants to merge 5 commits into
AngelPaella wants to merge 5 commits into
Conversation
send() and on() indexed their schema map and called .safeParse on the result with no missing-key check, so any event absent from the map threw a synchronous TypeError instead of degrading. send() now warns and transmits the event unvalidated. A missing key is a map-sync gap on our side, not bad caller data, and silently dropping the event would reproduce the exact failure the guard exists to prevent: a consumer that already listens for it stops receiving it with no signal. on() warns and skips the callback. Adds vitest to the package, which had no test setup, and covers both guards plus the mapped-event paths they must not disturb.
🦋 Changeset detectedLatest commit: c4081cd The changes in this PR will be included in the next version bump. This PR includes changesets to release 18 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
AngelPaella
marked this pull request as ready for review
August 10, 2026 13:48
Contributor
|
Reviews (1): Last reviewed commit: "fix(window): guard EventEmitter map look..." | Re-trigger Greptile |
Address review on #2010. send()/on() now test own-key presence on their schema maps instead of indexing blind, so prototype-inherited names no longer resolve to a non-schema value. send() throws a named error rather than transmitting unvalidated: an unmapped event means the peer's map is the one out of sync, and WindowTransport may be targeting "*". on() resolves its schema once at registration and no longer dereferences message.data unguarded. vitest config switched to jsdom with the @ alias, matching the config #2005 adds for the same path.
Contributor
|
Reviews (2): Last reviewed commit: "fix(window): check own-key presence and ..." | Re-trigger Greptile |
These tests use a fake transport and never touch window, so jsdom and the @ alias were both speculative. #2005 brings them in with the WindowTransport tests that need them.
Contributor
|
Reviews (3): Last reviewed commit: "test(window): keep the vitest config on ..." | Re-trigger Greptile |
vitest.config.ts: main's jsdom config wins. window-transport.test.ts drives real `window.addEventListener`, and the `@/utils/*` imports need the alias; the EventEmitter tests this branch adds pass under jsdom either way. #2017 landed a second EventEmitter suite as EventEmitter.test.ts. Its three timeout cases move into event-emitter.test.ts, which is where the repo's kebab-case convention puts them, and the PascalCase file is gone.
Contributor
|
Reviews (4): Last reviewed commit: "Merge origin/main into angel/window-miss..." | Re-trigger Greptile |
vitest 3.2.6 is already a root devDependency and pnpm puts the workspace root's .bin on a package script's PATH, which is how main runs these tests. Declaring it here left a lockfile importer entry that --frozen-lockfile rejected after the dependabot upgrade rewrote the resolution key.
Contributor
|
Reviews (5): Last reviewed commit: "Drop the local vitest devDependency and ..." | Re-trigger Greptile |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
EventEmitter.sendandEventEmitter.onindexed their schema map and called.safeParseon whatever came back:An event absent from the map threw
TypeError: safeParse is not a function. That turns a map-sync gap between a hosted page and the published contract into a runtime crash whose blast radius depends on the call site: from a synchronoususeEffectit reaches the nearest React error boundary and blanks the UI, and from an async continuation it becomes an unhandled rejection that strands the caller.Found while tracing the embedded-checkout v3 event contract, where the page validates sends against a local copy of the event map that has drifted four keys ahead of the published package.
Change
Both methods test own-key presence before they trust the lookup:
A
schema == nullcheck does not cover this.outgoingEvents["constructor"]returnsObjectandoutgoingEvents["toString"]returns a function, so both sail past a null check into.safeParseand throw the same TypeError.hasOwnPropertyseparates an own key from an inherited one. The repo compiles against the ES6 lib, soObject.hasOwnis out.sendthrows a named error identifying the event.onreports the gap throughconsole.errorat registration and drops matching messages.Why send throws rather than transmitting
The first revision of this PR warned and sent the event unvalidated, reasoning that a missing key is our map-sync gap rather than bad caller data, so consumers already listening should keep receiving. Two things sink that argument.
The peer does not know the event either. Version skew is symmetric. If the key is missing from our map, the frame across the postMessage boundary is the same frame whose map is out of date. Sending buys nothing and costs 30 seconds:
sendActionwaits outDEFAULT_EVENT_OPTIONS.timeoutMs(ncs-signer.ts:452), thenWebViewParent.sendActionreads that timeout as a dead frame, callsreloadAndHandshake()(rn-webview/Parent.ts:132), tears down the live signer WebView, and replays for another 30 seconds. A user who taps sign watches the wallet spin for a minute and loses the session to a reload. Throwing rejects in milliseconds, insidesendAction's promise executor, before any retry interval exists.Some transports target
"*".PopupWindowfalls back totargetOrigin: "*"when the caller passes none (windows/Popup.ts:67), andWindowTransport.sendhands that topostMessage. Transmitting unvalidated converts a thrown error into a broadcast of a payload that can carry a signature, an OTP, or an auth token, to whatever document occupies that popup.onstays non-throwing. Twenty call sites across react-ui and react-native register listeners inside mount effects, and a throw there converts a dead listener into a mount crash.Two more fixes inside the listener
on's listener readmessage.data.eventwith no null check, sowindow.postMessage(null, "*")from a browser extension threw inside every registered listener.WindowTransport.addMessageListenerfilters on origin alone, andisTargetOriginreturns true for every origin oncetargetOriginis"*", so any script on the page could fire it. Nowmessage.data?.event.The schema lookup also left the per-message closure. It ran on every inbound message and warned each time, so under
sendAction's 100ms retry polling a single missing key produced up to 100 identical lines before the action rejected with a timeout that named the wrong problem. The maps are assigned once in the constructor and never mutated, so resolving at registration is safe.Tests
The package had no test setup, so this adds
vitestand a config. These tests drive a fake transport and never touchwindow, so they run underenvironment: "node".#2005 adds the same config path with
jsdomand an@alias, which itsWindowTransporttests do need. That is an add/add conflict for whichever of the two merges second, resolved by taking #2005's version.Nine tests. Five fail against the pre-fix
EventEmitter, four controls pass either way:The fake transport keys listeners in a
Mapinstead of overwriting one variable, which is what gives theoff()test something to assert.Left out
HandshakeParentandHandshakeChildbuild their maps with... as any satisfies IncomingEvents, andEventEmitterOptions.incomingEventsis optional while the class generic is not. That pair is how a map and its declared type drift apart without a cast at the call site. Closing it means requiring the maps when generics are supplied, which changes a public signature.noUncheckedIndexedAccesswould let the compiler find every unguarded lookup instead of trusting that two call sites got patched by hand. Enabling it on this package surfaces two errors: the guarded lookups here, already written in a shape that satisfies it, andwindows/Popup.ts:111(parseInt(raw[2])on a regex match), unrelated to this PR.sendtransmits the caller'sdatarather thanresult.data, so zod validates without sanitizing. Switching would strip fields that live signer and checkout payloads may carry, and wants protocol review first.Notes
fix/eng4-360-listener-ids. That branch touches theListenerIdtype,on's signature, its return, andoff; this one touchessend's body and the inside ofon's listener. The second to merge rebases without conflict.crossbit-mainpins@crossmint/client-sdk-windowat0.2.3against1.1.0onmain, so that bump is separate and larger.Merged with main
maingained anEventEmittersuite of its own in #2017, asEventEmitter.test.ts. Its three timeout cases now live inevent-emitter.test.tsnext to these, which is where the repo's kebab-case convention puts them, and the PascalCase file is deleted. One suite per unit, 16 tests green.packages/client/window/vitest.config.tsarrived from both sides. Main's version wins:window-transport.test.tsdrives a realwindow.addEventListener, so the environment has to bejsdom, and the@/utils/*imports need its alias. The tests here pass under either environment.No local
vitestdevDependency: it is already a root devDependency, and pnpm puts the workspace root's.binon a package script's PATH. Declaring it here left a lockfile importer entry that--frozen-lockfilerejected once the dependabot upgrade rewrote the resolution key.