A field journal of the bugs, surprises, and design dead-ends we hit while building this library — and what we'd do differently next time. Skim it before the next round of changes; most of these will save you a half-day.
Each entry follows the same shape:
- Symptom — what we saw / how the bug surfaced.
- Root cause — what was actually going on.
- Fix — what we changed.
- Takeaway — the rule we'd write on the whiteboard.
Symptom. Codegen failed at iOS pod install with
Unable to determine event arguments for "onLoad", and TypeScript blew
up importing DirectEventHandler / WithDefault types.
Root cause. The library's tsconfig.json sets
customConditions: ["react-native-strict-api"]. Codegen's TS resolver
respects those conditions, and the strict-api entry point doesn't
re-export the codegen type namespace under its old paths.
Fix. In AudioWaveformViewNativeComponent.ts, import CodegenTypes
as a namespace from react-native and use qualified names everywhere:
import { type CodegenTypes } from 'react-native';
durationMs: CodegenTypes.Int32;
onLoad?: CodegenTypes.DirectEventHandler<OnLoadEvent>;
barWidth?: CodegenTypes.WithDefault<CodegenTypes.Float, 3.0>;Takeaway. When you set customConditions, every codegen type import
has to flow through the namespaced surface. Don't deep-import codegen
types from react-native/Libraries/... — that path bypasses the
condition resolver.
Symptom. example/src/App.tsx complained that ref doesn't exist
on AudioWaveformView after we added forwardRef only to the native
implementation.
Root cause. The package has two component files:
AudioWaveformView.native.tsx (loaded on iOS/Android by RN's platform
extension resolver) and AudioWaveformView.tsx (the web/Node stub used
for TypeScript). They have to expose the same component shape, otherwise
the type resolver picks the stub on tsc and complains.
Fix. Made the stub a forwardRef too, even though it just throws.
Takeaway. Whenever you change the public component's shape on
.native.tsx, mirror it on the plain .tsx stub. Add a comment up top
saying "this only exists for type parity with the native module."
Symptom. yarn typecheck exploded with
Internal Error: Package for react-native-waveform-player@workspace:. not found in the project.
Root cause. The lockfile pinned the old workspace name. When you
rename a workspace, yarn install has to regenerate the resolution
table.
Fix. Ran yarn install. Done.
Takeaway. Renaming a workspace? Always follow up with
yarn install (or npm install) before running anything else.
Symptom. Waveform decode silently failed for any https:// source.
Root cause. AVAssetReader only works on local files. AVURLAsset
will load a remote URL but the reader can't pull samples out of it.
Fix. For remote URLs, WaveformDecoder.swift first downloads via
URLSession.shared.downloadTask, then runs decodeLocalFile(...)
on the staged temp file.
Takeaway. "AVURLAsset accepts the URL" is not the same as "AVAssetReader can read from it." Local files only for the reader.
Symptom. Decode would download the file fine, then track(withMediaType: .audio) came back nil.
Root cause. AVURLAsset uses the file extension to identify the
codec. Google Drive download links look like
/download?id=...&export=download — no extension at all.
Fix. WaveformDecoder.audioFileExtension(from:) walks
response.suggestedFilename → MIME type → URL path extension → m4a
fallback, then writes the temp file with that extension before handing
it to AVURLAsset.
Takeaway. AVFoundation cares about file extensions, not MIME types. When you stage downloaded media, give it a sensible extension or nothing will decode.
Symptom. On a slow network, the bars view was visibly progressively filling in (so decode was working), but the play-button spinner refused to clear for ages.
Root cause. Two things compounded:
AVPlayer's defaultautomaticallyWaitsToMinimizeStalling = truemakes it wait for a generous forward buffer before flipping.readyToPlay. On slow networks this can add many seconds even after enough data has arrived to start playback.- The waveform decoder's
URLSession.downloadTaskandAVPlayer's streaming were running on the same HTTP connection at the same time, fighting each other for bandwidth. By the time the URLSession download finished (and the bars started painting),AVPlayerwas still stalled on its initial buffer.
Fix.
AudioPlayerEngine.swift: setplayer.automaticallyWaitsToMinimizeStalling = falseininit().AudioWaveformViewImpl.swift: deferreddecodeAmplitudesIfPossible()fromapplySourcetoengine.onLoad, soAVPlayergets undivided bandwidth during its initial buffer fetch.
Takeaway. When two subsystems share a network pipe, sequence them or they will starve each other. Specifically for AVPlayer, default buffering is conservative — opt into eager mode for short voice-note content.
Symptom. User taps play while the spinner is still showing → nothing happens. Even when the engine becomes ready a second later, you have to tap again.
Root cause. The naive engine play() early-returned when
state == .loading, with no record of the user's intent.
Fix. Added a pendingStart flag inside AudioPlayerEngine. play()
during .loading sets pendingStart = true and returns without
firing onStateChange (so the spinner stays). The state setter applies
the pending start atomically the moment it transitions to .ready,
firing one combined notification. Cleared on pause() / reset() /
new setSource().
Takeaway. "Async resource not ready" buttons should queue intent, not drop it. And queue it close to the resource (in the engine), not in the view layer — that way every entry path (uncontrolled tap, controlled prop, autoPlay) gets the behaviour for free.
Symptom. When a queued tap finally fired, the spinner cleared, revealed the play icon for one frame, then crossfaded to pause — visible flash.
Root cause. handleEngineStateChange was setting playButton.isLoading = false before playButton.isPlaying = true. So the imageView became visible (with the stale "play" icon) before its image was updated, and the play→pause transition then ran with the 0.12 s UIView.transition crossfade.
Fix. Update isPlaying first (while the spinner still hides the
imageView — the icon swap is invisible), then drop isLoading = false.
The imageView reveals already pointing at the right icon, no crossfade
needed. PlayPauseButton.swift skips the crossfade animation when
isLoading is still true at the moment isPlaying changes.
Takeaway. Order of two state-affecting writes matters when one is "hide a cover" and the other is "swap the thing under the cover." Always swap first, then uncover.
Symptom. Putting the component inside a vertical ScrollView (a
chat list, basically) made horizontal scrub gestures get cancelled the
moment the user moved more than a few pixels.
Root cause. UIScrollView's pan recogniser is hungry. Plain
touchesBegan/Moved/Ended will be cancelled by the scroll view as
soon as it decides the user is doing a real pan.
Fix. Replaced manual touch handling with a
UILongPressGestureRecognizer configured with
minimumPressDuration = 0. Long-press recognisers claim the touch
sequence on .began, which prevents the scroll view from stealing
it. Zero press duration means it fires immediately.
Takeaway. Reach for a gesture recogniser whenever you want to win
arbitration against a parent UIScrollView. touchesBegan is fine in
isolation but loses every fight with parent gesture recognisers.
Symptom. Calling ref.current.pause() paused the audio fine but
the icon stayed showing "pause" until the next play.
Root cause. The icon was being refreshed only by the periodic
display-link tick that we use for the bars view's progress fraction.
The display link stops the moment we pause, so the final tick (with
isPlaying = false) never ran.
Fix. handleEngineStateChange explicitly sets
playButton.isPlaying = engine.isPlaying on every state transition,
not just from the display-link tick.
Takeaway. If a UI element reflects state, refresh it from the authoritative state-change callback, not from a coalesced render loop. The render loop is for things that need to change continuously (progress fraction); discrete things should snap.
Symptom. Unmount the component, audio kept playing in the background.
Root cause. Fabric pools component views — AudioWaveformView (the
Obj-C++ shim) doesn't get deallocated when React unmounts; it goes
back into a recycler pool. The Swift AudioWaveformViewImpl it owns
stays alive too, with its AVPlayer happily playing.
Fix. Override prepareForRecycle in AudioWaveformView.mm and
call AudioWaveformViewImpl.tearDown(). tearDown() resets the
source to "" (which runs through the regular reset path: cancel
decoder, reset engine, stop display link) and clears every other
piece of bookkeeping state so the recycled view is indistinguishable
from a freshly allocated one.
Takeaway. Any Fabric component that owns a system resource (audio
player, video player, location manager, network connection) needs an
explicit prepareForRecycle teardown. Don't trust unmount.
Symptom. After the prepareForRecycle fix landed (#11), an unmount
mount-cycle bug surfaced in the example app: play → unmount → mount again
with the same source, and the new player started playing immediately
(audible in the foreground, kept playing in background with
playInBackground=true). At the same time the play/pause button still
showed the "play" icon as if paused. Engine state and AVPlayer were
out of sync, and ref.current?.pause() from the JS-side cleanup didn't
help.
Root cause. AVPlayer.rate is orthogonal to the current item —
it survives replaceCurrentItem(with:). Our engine.reset() was
clearing the item but not the rate, so:
- Tap play →
startPlaybackInternal()setsplayer.rate = 1.0. - Unmount →
engine.reset()→replaceCurrentItem(with: nil). Item gone, ourisPlayingflag flipped tofalse, butplayer.ratewas still1.0. - Remount →
engine.setSource(url)→replaceCurrentItem(with: newItem). From Apple's own docs: "the new player item begins playing at the current rate. If the player is in a paused state, the new player item is paused as well." Rate was non-zero → AVPlayer kicked the new item into playback the moment its status flipped to.readyToPlay.
Compounded by our automaticallyWaitsToMinimizeStalling = false
opt-in from #6 — that made .readyToPlay fire almost immediately, so
the auto-resume was instant. Engine bookkeeping said isPlaying = false
(button showed "play" icon) while AVPlayer was happily playing the new
item.
Android doesn't have this bug because engine.reset() over there calls
MediaPlayer.release() and the next setSource builds a fresh
MediaPlayer — no shared instance whose orthogonal state can leak.
Fix. Two explicit player.pause() calls in
AudioPlayerEngine.swift:
- In
reset(), beforereplaceCurrentItem(with: nil)— so a recycled view's underlyingAVPlayerstarts the next mount withrate = 0. - In
setSource(url:), beforereplaceCurrentItem(with: item)— defence in depth, so asourceprop swap mid-playback also lands in a deterministic paused state and waits for an explicitplay()/autoPlay/ controlledplaying={true}to resume.
Also reset rate = 1.0 in reset() for symmetry with Android, and
speedPill.setSpeed(1.0) in tearDown() so the pill doesn't flash a
stale "2.0x" between unmount and the next mount's defaultSpeed
re-application.
Takeaway. When you hand a long-lived native object back for reuse,
every field the OS treats as orthogonal to its current "task" is a
landmine. AVPlayer has at least three: rate, volume, and
actionAtItemEnd. If your reset() doesn't touch them, the next item
silently inherits them — and that's almost never what you want.
Recreating the underlying resource (what Android does) is the
brute-force defence; explicitly nulling each orthogonal field is the
surgical one. Pick one, but don't half-do it.
Symptom. During playback, the playhead's partial-fill bar didn't move smoothly — it would update once or twice a second, then pop.
Root cause. An early version of WaveformBarsView skipped
setNeedsDisplay() if the new progressFraction produced the same
pixel-rounded value as the previous one. Sounded like a sensible
optimisation, but with barWidth = 3 the rounded values change
infrequently and the partial-fill is exactly what we wanted to
update sub-pixel.
Fix. Removed the skip-redraw optimisation. The path is cached
anyway so the actual cost is one clipRect + one re-fill — cheap.
Takeaway. "Skip if the value didn't change" optimisations are risky on continuous-update rendering. Profile before adding; remove if anything looks janky.
Symptom. e: AudioWaveformEvent.kt: 'eventName' hides member of supertype 'Event'.
Root cause. Our custom event class declared a constructor parameter
named eventName, which collides with the inherited getEventName()
on Fabric's Event base class.
Fix. Renamed the parameter to name.
Takeaway. When subclassing platform classes (RN, Android, AppCompat),
spell-check your property names against the supertype before pushing.
Event, View, Context, etc. have lots of accessors that look
ordinary.
Symptom. Kotlin compile error
'An explicit type is required on a value parameter.' on
fun nextSpeed(after current: Float).
Root cause. Pasted that signature from the Swift side without
realising Kotlin doesn't support argument labels. Kotlin reads
after as the parameter name and current: Float as a syntax error.
Fix. fun nextSpeed(current: Float).
Takeaway. When porting Swift APIs to Kotlin (or vice versa), drop the argument labels — Kotlin uses positional / named-argument-at-call style instead.
Symptom. With playInBackground = true and no WAKE_LOCK
permission in the host app manifest, calling engine.play() did
nothing — no audio, no error in our code paths.
Root cause. We thought MediaPlayer.setWakeMode(...) would throw
SecurityException when the permission is missing, so we wrapped it in
a try/catch. Wrong: setWakeMode only registers intent to acquire a
wake lock. The actual WakeLock.acquire() happens later inside
MediaPlayer.start(). The SecurityException there was being swallowed
by the MediaPlayer internals, leaving us with isPlaying = true but
no audio.
Fix.
applyWakeModechecksManifest.permission.WAKE_LOCKviacontext.checkSelfPermission(...)before callingsetWakeMode, and skips it (with a clearLog.w) if not granted.play()also wrapsp.start()in a try/catch that resetsisPlaying = false, stops the progress loop, and firesonStateChangeif anything throws — so the UI doesn't get stuck.
Takeaway. "Where does the exception originate?" matters. Don't
assume the call you're protecting is the one that throws — read the
docs or the source. And start() is the public play entry point on
both platforms; harden it against any throw, not just the obvious ones.
Symptom. e: ... Unresolved label in AudioWaveformView.kt.
Root cause. onTimeUpdate is a property holding a lambda, not an
inline function — the implicit-label-from-call-site rule that lets
you write return@inlineFunc inside forEach { ... } doesn't apply.
The call site is engine.onTimeUpdate = { … }, and the lambda label
is engine (or whatever variable receives it), not onTimeUpdate.
Fix. Restructured the conditional to use an if (!cond) { ... }
block instead of an early return.
Takeaway. return@<label> only works for inline-function lambdas.
When you're assigning a lambda to a property, refactor the early-return
into structured if instead.
Symptom. Same as iOS — drag inside a parent ScrollView got
cancelled.
Fix. Call parent.requestDisallowInterceptTouchEvent(true) on
ACTION_DOWN. Android's equivalent of "claim this touch sequence."
Takeaway. Both platforms have a "tell my parent to back off" escape hatch; reach for it whenever a custom horizontal-drag gesture lives inside a vertical scroller.
Symptom. Apps using playing={...} saw the audio toggle on tap
even though they hadn't updated their state yet — then the state
caught up and produced a "double toggle" feel.
Root cause. Early code path called engine.toggle() first, then
fired onPlayerStateChange. In controlled mode that's wrong: the
component shouldn't mutate state at all, just emit the requested
new value.
Fix. Branch on controlledPlaying != -1 before doing anything,
emit the event with newPlaying = !engine.isPlaying, and don't touch
the engine.
Takeaway. Controlled-component contract: no internal state changes on user interaction. Emit the request, let the parent decide.
Symptom. Mount the component → blank waveform area for several seconds → bars suddenly appear → eventually you can play. No feedback that anything's happening.
Fix.
WaveformBarsViewpaints uniform placeholder bars (constant amplitude0.2) when no real data is available yet, then animates bar heights from placeholder → real over ~200 ms when the first amplitudes arrive.WaveformDecoderemits progressive partial amplitude arrays during decode (every ~5 % at first, then every ~20 %), so bars fill in left-to-right as the file decodes.PlayPauseButtonshows a native spinner (UIActivityIndicatorView/ProgressBar) whenengine.state == .loading.
Takeaway. "Empty UI while loading" reads as "broken." Always have something on screen — placeholder, skeleton, spinner — and animate into the real thing instead of popping.
Symptom. Profile traces showed the 30 Hz progress refresh keeping a few percent of CPU busy even when the app was backgrounded and the view wasn't being composited.
Root cause. The progress tick on both platforms updated the bars
view's progressFraction and the time label string regardless of
whether the view was visible.
Fix. Added pauseUiUpdatesInBackground (default true). When
backgrounded, skip the bars/time-label refresh inside
engine.onTimeUpdate. The JS onTimeUpdate event keeps firing so
Now Playing / Lock Screen / analytics integrations work the same.
Snap the UI back to current state on didBecomeActive / onHostResume.
Takeaway. Background work is cheap-but-pointless work, multiplied by 30/sec. When in doubt, gate it behind a lifecycle flag and snap on resume.
Symptom. Tests calling play() synchronously after mount
sometimes saw onPlayerStateChange arrive with isPlaying = false
before the "real" isPlaying = true event.
Root cause. With automaticallyWaitsToMinimizeStalling = false,
AVPlayer flips .readyToPlay very quickly. The state setter fires
onStateChange once when transitioning to .ready, then onLoad
runs, then if the user's onLoad handler calls engine.play(),
that fires another onStateChange. JS sees both.
Fix. Documented the contract on onPlayerStateChange: it's a
full snapshot fired on every transition, including load lifecycle.
Made it idempotent — receiving the same state twice in a row is fine,
parents should compare values rather than treating each event as a
delta.
Takeaway. When you collapse multiple events into "fire on every transition," document that consumers should diff against their own state, not treat each event as a unique edge.
A short list of "if we started over today":
- Define the codegen TS spec first, then build the public component type from it. Every mismatch we hit started with the public type drifting from the spec.
- Put the
pendingStartqueue in the engine from day one. We initially tried to handle it at the view layer; multiple entry points (tap, controlled prop, autoPlay) all needed the same fix. Putting it close to the resource was the right call. - Treat AVPlayer + URLSession sharing the network as a hazard. The bandwidth-contention bug took an embarrassing amount of time to diagnose — the symptom (waveform appearing while spinner stays) didn't immediately suggest "two HTTP downloads competing."
- Run
yarn installreflexively after any package.json change. Worth its own pre-commit hook honestly. - Write a teardown story before writing a mount story for any
Fabric component owning a system resource. Forgetting
prepareForRecyclemeans audio playing inside the recycler pool — really hard to reproduce in normal dev because the issue only surfaces under rapid mount/unmount cycles. - Audit which fields of a long-lived native object survive its
"current task." AVPlayer's
rate,volume, andactionAtItemEndall persist acrossreplaceCurrentItem— and that's how you ship a "the new source silently inherits the previous one's playback rate" bug (#12). Either explicitly null every orthogonal field inreset(), or recreate the underlying resource the way Android does. Half-measures are worse than either.