Add a loadingDelay prop - #3601
Conversation
Code reviewFound 4 issues:
Lines 244 to 270 in 327a92c
react-svg/test/browser.spec.tsx Lines 277 to 279 in 327a92c
Lines 284 to 286 in 327a92c
Updated: issue 1 was originally posted as "no paint observed in jsdom". That measurement was unsound — jsdom has no paint layer, as AGENTS.md notes. Re-measured in real Chrome with rAF sampling, which shows it does paint. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Milliseconds to wait before rendering `loading`, default 0. An injection that finishes sooner never renders it at all. The flash it suppresses is not new and not about the request cache: a 30ms cold load in Chrome 151 paints a loading element on 5 frames out of 14, which every version has done. NN/g's position is that a sub-second indicator is worse than none, because the user cannot tell what flashed. Default 0, so this is purely opt-in. The package cannot tell a spinner from a skeleton sized to reserve the SVG's space, and delaying the latter trades one layout shift for two, so only the consumer can choose. The delay is its own effect keyed on `isLoading` rather than a dependency of the injection effect, so changing it does not re-inject. Initial state reads the prop instead of starting false: effects run after paint, so starting false would cost the default a frame without the loader. `fallback` is not delayed. An error costs a round trip that no cache short-circuits, so there is no flash to suppress there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`hasLoadingDelayElapsed` was reset by the delay effect, which is keyed on `isLoading` and so only runs once the injection effect's render has committed. The render in between still saw the flag left true by the previous injection, so a re-injection mounted `loading` immediately regardless of the delay. Measured against dist/ in Chrome by sampling requestAnimationFrame, which runs immediately before paint: over 60 re-injections with a 5000ms delay the loader mounted every time, and 8 of those reached a painted frame. The runs that painted are the ones where it stayed mounted longest, 1.0-2.0ms against a 0.1ms floor, so a slower device widens the window. That is the flash the prop exists to suppress. Clearing it in the injection effect puts it in the same commit as `setIsLoading(true)`, so no render sees the stale value. The prop is read through a ref, like the callbacks above it, to keep it out of the injection effect's dependency list: changing the delay must not re-inject. It clears to `loadingDelay <= 0` rather than false for the reason the initial state does, or the default would lose a frame without the loader on every re-injection. The test counts renders of `loading` rather than querying the DOM, because the stale mount lasts a fraction of a millisecond: long enough for Chrome to paint, far too short to catch after the fact. Also renumbers three seeds the new tests shared with existing ones, which resolved to the same URL and so shared svg-injector's cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327a92c to
eed92b7
Compare
|
Three of the four are addressed in eed92b7 and the amended 4cba345. 1. Re-injection flash — fixed. Re-measured with the same Chrome probe, sampling
The positive control is the part that makes the zero meaningful: each iteration first runs an injection with an 80ms delay against a 400ms response, where the loader must appear. It did, 60/60, while the measured phase recorded none — so the harness was still sensitive rather than blind. The regression test counts renders of 2. Seed collisions — fixed. The three new tests moved to 190/191/192; the existing tests keep 133/134/135, so no existing URL changed. No seed is now used twice in 4. Commit body wrap — fixed by amending, hence the force-push. 3. Manual screen-reader harness — still outstanding, and now more relevant than when I flagged it: Verified locally on the pushed tree: 🤖 Generated with Claude Code |
Announcement behaviour is unchanged from 2026-08-04: step 0 announces, every other case is silent, and a 2515ms mount is as silent as the millisecond-scale ones. The entry says what the run does not cover, since that is easy to read the wrong way. `loadingDelay` defaults to 0, so every step here exercises the default path and none of them sets the prop. A delay long enough to suppress the mount leaves no element to announce, which the DOM log settles without a screen reader, and no step changes `src` on a mounted component, which is the path eed92b7 corrects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The delay effect was keyed on `isLoading` and `loadingDelay`. A re-injection that begins while the previous request is still in flight leaves `isLoading` true the whole way through, so when `loadingDelay` is held constant, which is the usual way to pass it, neither dependency moved and the effect never re-ran. That showed up two ways: - If the first delay had elapsed, clearing the flag left nothing to set it again, and `loading` stayed suppressed for the whole of the second injection however slow it was. - If it had not, the first injection's timer survived and fired against the old start time, so `loading` appeared early. The first is a regression from clearing the flag in the injection effect: before that the flag stayed true and the loader stayed on screen. An `injectionId` counter, bumped whenever an injection starts, gives the effect something that moves every time. It is skipped for the first injection so mount still settles in one render. Clearing the flag in the injection effect stays as it was: the delay effect still only runs a render later, and without the clear that render would see a stale true. The existing re-injection test moved both of the original dependencies at once, waiting for the first injection to finish and also changing the delay, which is what hid the case where neither moves. Four tests now walk the rest of the matrix. Screen-reader run re-recorded against the fix. Announcement behaviour is unchanged; the harness still has no step that swaps `src` on a live component, so it does not reach either re-injection path.
Code reviewRe-reviewed the delta since the last round (
Lines 261 to 289 in b06a2cb Two ways it showed. If the first delay had elapsed, clearing the flag at L132 left nothing to set it again, so Verified rather than argued: a test that mounts with The remaining cells of the matrix were fine — re-inject after completion with the delay unchanged, re-inject mid-flight with the delay changed, and re-inject after an error all pass on The fix adds an
The screen-reader harness was re-run against the fix and re-recorded in the same commit, since the 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Both Chrome measurements this branch has relied on came from a probe that was never committed, so neither could be re-run. This is that probe, as a step in the harness. It swaps `src` on a live component with `loadingDelay` set, which no other step does - the rest remount a fresh tree - and samples requestAnimationFrame, which is the question jsdom cannot answer at all: `loadingDelay` exists to keep a loader off the screen, and only a real browser paints. Two phases that control each other. `rearm` gives the second injection long enough that the loader has to come back; `suppress` gives it less than the delay, so the loader must stay down. A zero from `suppress` means nothing on its own, and means a good deal next to thirty from `rearm` in the same sitting. Recorded run is Chrome 151 on macOS, 30 runs a phase: rearm 30/30 mounted and painted, suppress 0/30 both, nothing left on screen at the end of either. Against dist/ built from b06a2cb, the commit before the fix, rearm reads 0/30 - so the probe can see the regression rather than only agreeing with the current code. Needs no screen reader, so unlike the rest of the harness it reads the same however it is driven.
Adds
loadingDelay, milliseconds to wait before renderingloading. Default0, so this is purely additive and changes nothing for anyone who does not set it. An injection that finishes sooner than the delay never rendersloadingat all.Why
The flash this suppresses is not new, and it is not about the request cache. A 30ms cold load — localhost, a warm CDN edge, a
file://read — paints a loading element and pulls it away again. NN/g's position on progress indicators is that a sub-second one is worse than none, because the user cannot keep up with what happened and may feel anxious about whatever flashed. 200–300ms before revealing an indicator is the usual industry choice.Measured in Chrome 151, six icons per case, against a real server:
loadingDelay02002002000A is the case the prop exists for: the loader paints on 5 frames out of 14 and is gone. C shows the delay honoured to within ~6ms of the 200 asked for, so a genuinely slow load still gets its indicator. B and D suppress at the DOM level rather than the paint level, which is a stronger result than a paint-level A/B: an element never in the DOM cannot paint, and cannot be observed by assistive technology either.
Why the default is
0This package cannot tell a feedback
loadingcomponent from a layout reservation one. A spinner benefits from a delay; a skeleton sized to hold the SVG's space exists to keep Cumulative Layout Shift down, and delaying that turns one layout shift into two. Only the consumer knows which they wrote, so the prop is opt-in.An earlier framing of this rested on a screen-reader argument — that a briefly-mounted
loadingelement might announce. That was measured separately and reported no: VoiceOver ignores any live region inserted already-populated, which is how React mounts aloadingcomponent, so lifetime never mattered. This lands on the flash alone.Notes on the implementation
isLoading, not a dependency of the injection effect. AddingloadingDelayto that effect's dependency array would make changing the delay re-run the injection. Keyed this way, the injection effect settingisLoadingback totrueis what restarts the delay for a re-injection.useState(loadingDelay <= 0)) rather than startingfalse.useEffectruns after paint, so startingfalsewould cost the default one frame without the loader — a regression in exactly the path this must leave alone. The 22 existing snapshots passing unchanged is the check on that.<= 0, not=== 0. A negative delay would otherwise reachsetTimeout, which clamps it to0and fires in a later task, holding the loader back for a caller who asked for no delay.fallbackis not delayed. An error costs a round trip that no cache short-circuits, so there is no flash to suppress and delaying the message is a straight downgrade. Stated in the README rather than left implicit, and pinned by a test.Tests
Four, written before the change. Two were red for the right reason — the loader present when it should have been held back. The other two are pins on paths that must not move:
loadingDelay={0}renders immediately, andfallbackis not delayed. Both passed before the change, which is the point of them.npm testis green: 46 tests, 100% statement coverage onReactSVG.tsx, all seven React versions (16.8, 16.14, 17.0, 18.0, 18.3, 19.0, 19.1).size-limitreports 1.76 kB ESM against the 2 kB budget and 2.22 kB CJS against 2.5 kB — roughly 100 bytes gzipped for the extra state and effect, so no budget change.No example added:
examples/loadingpinsreact-svg: "latest", so it cannot demonstrate an unreleased prop. That is a follow-up for after the release.🤖 Generated with Claude Code