How Shader Effects Wires a Shared WebGPU Layer Into React, Vue, Svelte, and Solid

How Shader Effects Wires a Shared WebGPU Layer Into React, Vue, Svelte, and Solid

pr0h0•
webgpushader-effectsreactvuesvelte
AI Usage (97%)

Why a shared WebGPU layer across React, Vue, Svelte, and Solid is really a lifecycle problem

A shared WebGPU layer that works in React, Vue, Svelte, and Solid sounds like a solved problem until you sit down to write one. This post is about how such a layer wires into four different reactivity models, and what to verify before you adopt it. The GPU part is roughly fifteen lines and identical in all four frameworks. The wrapper around it is where the bugs live, because the four frameworks disagree about when effects run, when teardown runs, and who owns the DOM node.

I think the framing of the announcement undersells the risk. "WebGPU library for React, Vue, Svelte, and Solid" makes it sound like a portability problem. It's a lifecycle problem with a graphics API stapled to it. If you're evaluating this library for a real app, the questions that decide it are: does it own the device or accept one, what does it do on device.lost, and does it survive a mount/unmount loop under React StrictMode.

What the Shader Effects open-source announcement actually claims

Confirmed from the report

The coverage I was given, published by news.lavx.hu on 2026-10-07 and surfaced through Google News, states three things:

  • Shader Effects has open-sourced a WebGPU library
  • It targets React, Vue, Svelte, and Solid
  • It exposes a shared abstraction for GPU-driven visual effects

That is the whole claim. Everything else in this post is about how I would evaluate such a library, not a description of what it does.

What the report does not establish

No package name, repository, license, or version. No bundle figures, no SSR story, no fallback tiers, nothing about device ownership, and nothing about how device loss is handled. I have no confirmed package name, so I cannot install it, measure it, or show you its output. Any number you see below is the shape of the output from a command you run yourself.

📝

Treat the missing details as the actual evaluation checklist. A wrapper that gets the device model wrong will look fine in a demo and leak adapters in an app.

The hard part is not WebGPU, it is four different reactivity models

Where lifecycle and teardown hooks live in React, Vue, Svelte, and Solid

FrameworkSetup hookTeardownAsync init hazard
React 18/19useEffect / useLayoutEffecteffect cleanup fnStrictMode double-invokes effects in dev
Vue 3onMountedonBeforeUnmount, onScopeDisposeref is null before mount
Svelte 5$effect / $effect.prereturned functioneffects rerun when tracked values change
SolidonMountonCleanupowner context is lost after await

React is the case that catches people, because requestAdapter() is async and the effect cleanup is not:

EffectCanvas.jsx
import { useEffect, useRef } from "react";

export function EffectCanvas({ params }) {
const canvasRef = useRef(null);
const paramsRef = useRef(params);
paramsRef.current = params; // do not re-run the effect on every param change

useEffect(() => {
  let disposed = false;
  let device, raf;
  (async () => {
    const adapter = await navigator.gpu?.requestAdapter();
    if (!adapter) return;
    device = await adapter.requestDevice();
    if (disposed) { device.destroy(); return; } // component already unmounted

    const ctx = canvasRef.current.getContext("webgpu");
    ctx.configure({
      device,
      format: navigator.gpu.getPreferredCanvasFormat(),
      alphaMode: "premultiplied",
    });
    raf = requestAnimationFrame(function frame() {
      // ...encode and submit
      raf = requestAnimationFrame(frame);
    });
  })();

  return () => {
    disposed = true;
    cancelAnimationFrame(raf);
    device?.destroy();
  };
}, []);

return <canvas ref={canvasRef} />;
}

In React 18/19 dev, StrictMode runs that effect twice. Without the disposed flag you request two adapters and destroy only one — a leak that disappears in production and reappears in every local debugging session. If a library doesn't guard this, that's a bug, not a nitpick.

Solid's version looks similar but behaves differently. onMount runs once and is not reactive — it's a createEffect with no dependencies — while createEffect reruns whenever a tracked signal it reads changes. Ref assignment happens during render, before mount. The subtle trap is registering cleanup after an await:

onMount(async () => {
  const device = await (await navigator.gpu.requestAdapter()).requestDevice();
  onCleanup(() => device.destroy()); // owner may already be wrong here
});

I would capture the owner with getOwner() before the await and re-enter it with runWithOwner() before registering teardown. I haven't verified that against whatever version this library pins, so treat it as a known Solid sharp edge rather than a confirmed defect.

Canvas ownership, refs, and remount behavior

A GPUCanvasContext is bound to one canvas element. If the wrapper creates the canvas internally, you can't lazy-load it, style it, or control its aspect ratio, and every remount gives you a new element that needs a fresh configure() call. My position: the component should own the <canvas>, the library should own the context.

That matters most under conditional rendering. {on && <EffectCanvas />}, v-if, {#if}, and <Show> all destroy and recreate the element. canvas.getContext("webgpu") returns the same context object for the same canvas, but a new canvas gives you a new one, so configure() must re-run. Test it by toggling the component ten times and watching for dropped frames on re-show.

What a shared GPU abstraction has to own

Adapter, device, and canvas context setup

This is the design decision the announcement skips, and the one that matters. Requesting a device is expensive; you want exactly one per app. Multiple canvases can share a device and a queue, each with its own context and swap chain.

Two things wrappers routinely get wrong:

  • Hardcoding the swap chain format. navigator.gpu.getPreferredCanvasFormat() returns "bgra8unorm" on most desktop browsers and "rgba8unorm" on Android. If the pipeline's target format doesn't match the context format, you get a validation error at draw time, not at configure time.
  • Throwing on a null adapter. requestAdapter() resolves to null when there is no usable GPU — headless CI, blocklisted drivers, remote desktop, or a user who disabled hardware acceleration. That's a normal outcome, not an error.

Binding component state to GPU buffers and uniforms

Reactive value into a uniform is cheap and should be your default. GPU output back into a reactive value is not: it needs mapAsync, which is a frame or more behind, and doing it per frame will stall the pipeline.

// one Float32Array reused forever; 4 vec4s = 64 bytes, 16-byte aligned
const staging = new Float32Array(16);

function syncParams(uniformBuffer: GPUBuffer, params: { t: number }) {
  staging[0] = params.t;
  // writeBuffer requires offset and size to be multiples of 4
  device.queue.writeBuffer(uniformBuffer, 0, staging);
}

Two layout rules that bite: in WGSL, vec3<f32> has size 12 but alignment 16, so a struct of three-vectors needs explicit padding; and minUniformBufferOffsetAlignment is 256 bytes on most implementations, so dynamic offsets into a packed uniform buffer must be multiples of that.

Effect graph versus render loop

Reactivity is push-based and per-value. Rendering is pull-based and per-frame. The bridge rule I would enforce on any such library: reactive effects write into a staging buffer and nothing else; the requestAnimationFrame callback is the only code that encodes and submits.

If the library instead submits a pass per reactive update, you get an unpredictable number of passes per frame, ordering bugs when two uniforms change in the same tick, and writeBuffer calls that scale with UI activity rather than frame rate. Under ten writes per frame, writeBuffer is fine. At a hundred, pack into one buffer with dynamic offsets.

SSR, hydration, and capability detection

Checking navigator.gpu without breaking the build

const hasWebGPU = typeof navigator !== "undefined" && "gpu" in navigator;

Run that inside an effect, never at module top level in code that a Node or edge prerender will execute. And the check is necessary but not sufficient: navigator.gpu can exist while requestAdapter() still resolves to null. Presence is not availability, and I've seen wrappers treat them as the same thing.

What a server-rendered pass of a WebGPU effect can and cannot do

A server render can emit the <canvas> element with fixed width and height, ship the WGSL source as a string, and precompute the effect graph. It cannot request an adapter, call getPreferredCanvasFormat(), allocate buffers, read device limits, or schedule a frame.

The hydration constraint that follows is strict: the server's markup and the first client render must match. So the wrapper must not branch on navigator.gpu during the first render. Render the fallback tier, then upgrade after mount.

Fallback tiers: WebGPU, WebGL, Canvas2D, static image

TierCheckWhat you give up
WebGPUadapter resolves, device not lostnothing
WebGL2getContext("webgl2")compute shaders, storage buffers
Canvas2DgetContext("2d")per-pixel effects, framerate
Static imagealwaysmotion

Pick the tier once per app, not per component, or you get a page with three different quality levels in one viewport. Re-evaluate on device loss, not on every render.

Bundle size: what shipping WGSL and a wrapper actually costs

WGSL ships as a JavaScript string. Text doesn't minify well but compresses well, because shaders are repetitive. The wrapper itself is small; the cost is whether it lands in your entry chunk or a lazy one. Dynamic-import() the GPU code inside the mount effect and it becomes a separate chunk that only users with WebGPU download.

Measuring it in a real production build instead of trusting a README

npm i <package>
npx vite build
npx source-map-explorer 'dist/assets/*.js'

You're looking for the shape below — these are not measurements of this library, they are what the output looks like:

dist/assets/index-a1b2c3.js        182.41 kB │ gzip: 58.02 kB
dist/assets/webgpu-d4e5f6.js        41.09 kB │ gzip: 14.87 kB
  └─ <package>/dist/index.js        18.62 kB
  └─ shaders (inlined WGSL strings) 12.04 kB

Measure the delta against the same route with the component removed, measure brotli as well as gzip, and ignore Bundlephobia for anything tree-shakeable — it will be wrong in both directions.

⚠️

Any bundle figure quoted in a README is unminified, untree-shaken, or both. Measure your own build on your own route.

Performance checks that matter more than an FPS number

FPS counters lie under vsync. Measure frame time instead: record requestAnimationFrame deltas and look at p50 and p95, not the mean. For GPU-side cost, request the timestamp-query feature (device.features.has("timestamp-query")) and resolve a query set into a readback buffer — remembering that the readback is a frame behind, so this is a diagnostic, not a control loop. queue.onSubmittedWorkDone() is the cheaper sanity check that work is actually finishing.

Per-frame allocations are the more common regression. No createBuffer, no new descriptor objects, no new Float32Array inside the frame callback. GC pauses show up as isolated 20ms+ spikes, not a steady framerate drop, which is why averaging hides them.

Then test device loss, because it isn't hypothetical — drivers reset, laptops switch GPUs, and users toggle hardware acceleration:

device.lost.then((info) => {
  // reason is "destroyed" if you called destroy(), otherwise "unknown"
  console.warn("device lost:", info.reason, info.message);
});

Force it from the console with device.destroy() and watch what the component does. If it freezes silently, the library has no recovery path.

Evaluating the library in your own app

The single best test is counting adapter requests. Patch navigator.gpu before your app boots:

const gpu = navigator.gpu;
const realRequestAdapter = gpu.requestAdapter.bind(gpu);
let adapterCalls = 0;
gpu.requestAdapter = async (...args) => {
  adapterCalls++;
  return realRequestAdapter(...args);
};

Then mount and unmount the effect component five times and read adapterCalls. If it stays at 1, the layer really is shared. If it reaches 5, "shared abstraction" means shared source code, and each component is building its own device — which is the failure mode demos hide.

Combine that with the lifecycle table above: confirm deterministic SSR markup, confirm cleanup runs under StrictMode and under a conditional render, confirm device loss is handled, confirm the WGSL is readable source you can audit, and confirm the GPU chunk is lazy. If the package clears those five, the cross-framework claim is worth something. If it only clears the one your demo exercised, you've adopted four integration risks to avoid writing forty lines of setup.

Further Reading

Share this post

More posts

Comments