The 0.6.0 work was a port. Twenty-five commits of editor work, built in a premium product, had to be carried into the open-source @elah/* packages without dragging along the parts that product does not share. The unit tests passed. It would have been very easy to merge on the strength of that.
We verified it instead, by running the thing and reading what the port had actually produced. That surfaced eight real defects. They are the Fixed list in the 0.6.0 changelog, and this post is about why they were there and what finding them took.
Why verify a port at all
A port is a translation, and translation loses things at the seams. Code is copied across a boundary where the surrounding context is different: a different set of exports, a different stylesheet, a different set of callers. Anything the original code relied on implicitly is a candidate for going missing, and implicit reliance is by definition not something a test was written to protect.
That is the shape of every defect below. Not one of them is a logic error a type checker would flag. They are an ordering mistake, a missing registration, a dropped optimisation, a stylesheet that did not travel. A port whose tests pass is not a port that works, because the tests describe the behaviour someone thought to write down, and a port is exactly the situation where the behaviour that matters is the behaviour nobody wrote down.
The eight defects
- Video frames could upload into a disposed GPU texture after a clip's source changed. The layer read the texture before it replaced the provider, so a re-pointed clip kept writing into the old one.
- A preview loading spinner could never clear after a failed decoder re-open. The failure watcher was never re-armed on the replacement provider.
- A borrowed frame was stretched to the stage between clips, because the held-over frame was fitted by the incoming clip's dimensions, which were not known yet.
- The timeline ruler showed 00:90 where it should have shown 01:30. Seconds never rolled over into minutes.
- The clip loading shimmer had no CSS at all, so a pending clip showed nothing.
- Horizontal clip virtualisation had been silently dropped in the port, so every clip on a long timeline was mounted.
- Preview overlays and clip badges used hard-coded colours and could not be themed.
- In the web playground, deleted imported media was resurrected from IndexedDB, and its object URLs were never revoked.
Read down that list and notice how few of them announce themselves. The ruler bug is visible, if you look at a ruler past a minute. The virtualisation bug is invisible until a timeline is long enough to hurt. The shimmer defect is the absence of something: a loading state that looks, to the user, exactly like nothing happening. The theming defect is invisible in the one theme the author was looking at.
The disposed texture bug
The first one is the deepest, and it is a good example of a bug that lives in the order of two lines. In the video layer, draw() needs a texture to upload the current frame into, and it needs a provider (the thing that decodes frames for this clip). Usually both exist already, created when the clip entered the scene. But a clip can have its src changed while it is on screen, and when that happens the layer has to throw away the old provider and make a new one.
Throwing away the provider also disposes the clip's texture and removes it from the layer's map. A VideoTexture hands its pooled GL texture back to the pool when it is disposed. The defect was that draw() read the clip's texture into a local variable first, and only then asked for the provider. When the source had changed, the provider call disposed and unmapped the texture, but the local variable still held it and was still truthy. The frame was then uploaded into a texture the layer had already given up. A texture that is no longer in the layer's map is one the layer will never bind for that clip and never release again.
The fix is an ordering change. draw() now makes sure the provider is current before it reads the texture, and if the texture is missing afterwards it creates a fresh one and tracks it. The comment in the code says it plainly: ensure the provider before reading the texture, because a source change disposes and unmaps the old texture inside the ensure step.
The second half of the fix is about reference counts, and it is the part that is easy to miss. The layer keeps a reference count per clip, raised when the clip is acquired for drawing, and a provider sitting at a count of zero is fair game for two things: idle eviction, which disposes it after a timeout, and the prewarm pass, which seeks it ahead of an upcoming cut. When a provider is replaced because the source changed, the new one used to start at zero. The clip was still being drawn, but the layer now believed nobody was using its decoder. A replaced provider now inherits the reference count of the one it replaces, so a live provider is never exposed to eviction or a prewarm seek.
The spinner defect is its sibling. A provider can fail to re-open its container, and the watcher that turns that failure into an error state on the clip was attached to the old provider only. The fix re-arms it on the replacement, and disposing a provider now always clears the clip's load state, so the preview can never be left showing a spinner for a clip nobody is waiting on.
What only a build catches
Some of these defects cannot be found by running tests against local source, because the thing that is wrong is not in the source. The shimmer is the cleanest example. The component rendered, carrying the right class name. The class name had no rule behind it, because the stylesheet that defines it, @elah/timeline/styles, had not been given the shimmer. A test that renders the component sees the class on the element and passes.
The only check in the repository that exercises the published packages instead of local source is the root verify:examples script. It builds the three apps in examples/ (a minimal Vite app, a fuller React editor and a Next.js app) against the packages as they are published to npm. Those apps import what a real consumer imports and load the stylesheets a real consumer loads, so a missing export or a missing rule fails the build of an app that has no knowledge of our internals.
Be precise about what that gives you. It checks what is published, so it can only confirm a release after the packages are on npm; the examples were verified against 0.4.1 from a clean registry install, with full MP4 exports in the React and Next apps under both the dev server and a production build. For a release that is not yet published, the same discipline applies by hand: import the package the way a consumer does, from a place that does not know where your source lives.
A checklist for your own ports
None of this is specific to a video editor. If you are carrying code across a boundary, these are the questions that found our eight.
- Diff the public surface, not just the code. List what the source exports and what the destination exports, and explain every difference. A silently removed export is a break you have not met yet.
- Diff what the code assumed was there: stylesheets, design tokens, polyfills, build config. A class name that no rule defines is invisible to every test that renders it.
- Check every optimisation survived. Virtualisation, memoisation and caching are easy to drop because removing them changes no behaviour, only cost.
- Look for ordering in lifecycle code. Wherever one step disposes something and a later step reads it, check which comes first.
- Follow every counter and every owner through a replacement. When you swap an object for a successor, say what the successor inherits.
- Try the long input. Past a minute on a ruler, past a screenful of clips, past a session restart. Most of these defects appear only at the edge of the data.
- Build the consumers against the real artefact, and mutation-check each fix by reverting that one change and confirming the check fails.
A green test suite is a statement about what you thought to check. A port is the moment to check what you did not.
Building something with browser-native video?
Try the SDK, read the docs, or join the conversation.