feat(v4): port the resource loading helpers - #828
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #828 +/- ##
=======================================
Coverage 97.16% 97.16%
=======================================
Files 170 170
Lines 4133 4133
Branches 1152 1152
=======================================
Hits 4016 4016
Misses 106 106
Partials 11 11
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Code ReviewRisk: Low — The change adds the three resource-loading helpers and their barrel and subpath exports without identifiable blocking defects; it is safe to merge. Ports redesigned image, script, and link loading utilities to v4. Images remain detached and use decode with event fallback, while scripts and links load in the document head with shared URL-based deduplication and structured errors. Review usage: 32,292 in (21,341 cached) / 805 out tokens — $0.0103 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit b16a212. Previous review runsPrevious run archived 2026-08-16T11:29:22ZCode ReviewRisk: Low — No concrete defects found; the loading helpers and their utility entry points are safe to merge. This change adds detached image loading with decode fallback, head-appended script and link loading with shared URL-based deduplication, retry handling, and real loading errors. It also exposes the three helpers through the utilities barrel and generated subpath entry points. Review usage: 34,895 in (25,425 cached) / 1,209 out tokens — $0.0108 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 14409ca. |
Export sizeBundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped. @studiometa/js-toolkit-v4
Unchanged (381)@studiometa/js-toolkit
@studiometa/js-toolkit-v4
|
Merging this PR will regress 1 benchmark
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | translate3d only (x, y, z) |
294.5 µs | 345.5 µs | -14.75% |
| ⚡ | progress update (5 transforms) |
364.9 µs | 309.7 µs | +17.81% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing feature/v4-load-helpers (b16a212) with main (a00ca20)
Footnotes
-
141 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
v3 shipped a generic `loadElement(src, type, { appendTo })` with five aliases
over it. The generic form is what made every one of them wrong somewhere, so
v4 ships three helpers whose rules differ because the resources differ.
`loadImage` never touches the document and awaits `decode()`, so the bitmap is
ready when the promise resolves and assigning the URL paints without jank.
`decode()` is not trusted on its own: it rejects with an `EncodingError` on
images that load — cross-origin ones in Safari, and in Chromium on any decode
failure — so the load/error events stay the authority on whether the load
worked, and the same events answer when `decode()` is unavailable.
`loadScript` and `loadLink` append to `<head>`, with no option to put them
elsewhere: a detached script never runs and a detached link is inert. Both
deduplicate on the resolved URL, so asking twice for a third-party embed
executes it once, and `loadLink` keys on `rel` as well — prefetching a URL and
loading it as a stylesheet are two intents on one href. The maps live in a
shared runtime slot, so two evaluated copies of the package cannot double-
execute an embed. A failed load is forgotten, so a network blip does not make
a URL unloadable for the life of the page.
`rel` is required, because a `<link>` without one would never settle, and a
`rel` the browser ignores resolves at once rather than waiting for an event
that will never come — Safari ignores `prefetch` entirely, and a hint is
best-effort.
Everything rejects with a real `Error` naming the source and carrying the
event as its cause. v3 rejected with a plain `{ event, element }`, so
`error.message` was `undefined` and reporters dropped the failure.
`loadIframe`, `loadEmbed`, `loadTrack` and `loadElement` itself are not
ported: no consumer, and an `<iframe>` you want in the page you write in
markup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
The barrel fronts every utility module, and its spec asserts that it names every runtime export of every module it fronts, so the three helpers join both together. `npm run subpaths` then mints the per-symbol entry points, which is how a consumer imports one helper without pulling the barrel's graph. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
14409ca to
b16a212
Compare
Ports v3's resource-loading helpers to v4, redesigned. One module,
packages/v4/src/utils/load.ts, three functions:The six v3 defects this fixes
{ event, element }object, socatch (error) { error.message }wasundefinedand error reporters dropped the failure entirely. Every rejection is nownew Error(`Failed to load "${src}".`, { cause: event })— a realError, naming the source, with the event kept as the cause.loadevent, which fires before the bitmap is decoded, so assigning the URL to a visible<img>decoded on the main thread and janked the next frame.loadImageawaitsdecode()instead.loadScriptexecuted a third-party embed once per call. Nothing deduplicated: two components asking for the same maps/analytics/player SDK appended two<script>elements and ran it twice.loadScriptandloadLinknow deduplicate on the resolved URL (new URL(src, location.href).href) and hand back the very same promise. The maps live in a shared runtime slot (getSharedRuntimeSlot('load', …), the patternstorage/providers.tsandutils/focus.tsuse), so two evaluated copies of the package cannot double-execute an embed either.loadLinkcould never settle.loadLink(src)set onlyhref; a<link>with norelis inert and fires no event, so the promise hung forever.relis now required by the type.<link>in a browser that ignores itsrelleaked the same way. Safari ignoresprefetchentirely — no load event, ever.loadLinkasksrelList.supports(rel)first (see below).loadImagehad a pointlessappendTo,loadScripta dangerous one. v3's one generic took{ appendTo }for all six element types: an image never needs the document, and a script or a link put anywhere but<head>is either inert or a bug. The option is gone;loadImagenever touches the document,loadScript/loadLinkalways append to<head>.decode(), measured in real Chromiumdecode()cannot be trusted alone as a failure signal, which the specs prove rather than assume. Removing the events fallback makes three specs fail, including the plain "a broken image rejects" one: fordata:image/gif;base64,bm90LWEtZ2lm, Chromium'sdecode()rejects with a rawEncodingErrorDOMException, which would have escaped as the caller's error — the same shape Safari produces on cross-origin images that load perfectly. The two are indistinguishable from the rejection.So the load/error events are the authority and
decode()is only ever the fast path:A rejected
decode()on an image that did load falls through to an already-resolvedsettled; a rejecteddecode()on a genuine failure falls through to the realError. The same path covers a browser with nodecode()at all. Three specs cover the three cases, plus one that wraps the realdecode()to assert the promise does not resolve before it does.An unsupported
relresolves at oncedocument.createElement('link').relList.supports(rel)answers whether the browser will act on arel. When it will not, the element is still appended and the promise resolves immediately with it — a hint is best-effort, and waiting for an event that is never coming is a leak, not caution. This is the owner's leaning, and it is documented in the JSDoc. The spec asserts it; with the check removed, that spec fails on a 15-second timeout, which is exactly the leak.Multi-token
relvalues are supported (supports()throws on an empty token, so tokens are split and any supported one counts).The four
@studiometa/uiloadImagecall sitesAll four
await loadImage(src)then assign the URL, and use nothing from the return value. They translate with no change but the import, and each one silently gains the decode:Figure/AbstractFigure.ts:86try { await loadImage(src) } catch { … }this.src = srcnow paints without a decode on the main threadFigure/AbstractFigureDynamic.ts:67try { await loadImage(original) } catch { … }FigureVideo/FigureVideo.ts:70loadImage(poster).then(() => …).catch(() => …)FigureVideo/FigureVideoTwicpics.ts:124loadImage(twicPoster).then(() => …).catch(() => …)The one behaviour change they see: their
catchblocks now receive anErrorwith a message instead of{ event, element }. All four discard the argument and log their own message, so nothing breaks — and anything that did readerror.messagestarts working.Prefetch/AbstractPrefetch.tsis the other consumer this unblocks: it hand-rollsdocument.createElement('link'),rel = 'prefetch', a staticSetof prefetched URLs and aloadlistener. That isloadLink(url.href, { rel: 'prefetch' })— with the dedup shared across evaluated copies rather than per class, and no hang in Safari.Deliberately dropped
loadIframe,loadEmbed,loadTrackand the genericloadElementare not ported. Zero consumers across@studiometa/ui, and an<iframe>you want in the page you write in markup rather than build in JavaScript. The generic is what forced every helper to share one wrong set of rules in the first place.One judgement call worth flagging
A failed load is forgotten, so a later call retries instead of replaying a stored rejection — a network blip must not make a URL unloadable for the life of the page. The failed element is removed from
<head>too. The eviction happens inside the error listener, beforereject(), so no.catch()is attached internally and an unhandled rejection still surfaces to the console. Spec:rejects with an Error naming the source, and allows a retry.Verification
npm run lint,npm run lint:types,npm run test:v4(991 tests, 73 files, all passing) andpackages/v4 → npm run check:packageare green on each of the two commits. The 17 new specs run in real Chromium under vitest browser mode and touch no network: data URIs for the successes, a same-origin 404 for the failures.Three mutations were run against the implementation to confirm the specs bite: dropping the
decode()fallback fails 3, droppingrelfrom the dedup key fails 1, and always waiting for a load event fails 1 on a timeout.🤖 Generated with Claude Code
https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9