diff --git a/apps/typegpu-docs/src/examples/react/triangle/index.tsx b/apps/typegpu-docs/src/examples/react/triangle/index.tsx
index 8401cea97..4626f459d 100644
--- a/apps/typegpu-docs/src/examples/react/triangle/index.tsx
+++ b/apps/typegpu-docs/src/examples/react/triangle/index.tsx
@@ -1,10 +1,43 @@
import * as d from 'typegpu/data';
-import { useFrame, useRender, useUniformValue } from '@typegpu/react';
+import {
+ useFrame,
+ useMirroredUniform,
+ useRender,
+ useUniformValue,
+} from '@typegpu/react';
import { hsvToRgb } from '@typegpu/color';
+// TODO: We can come up with a more sophisticated example later
+function ColorBox(props: { color: d.v3f }) {
+ const color = useMirroredUniform(d.vec3f, props.color);
+
+ const { ref } = useRender({
+ fragment: () => {
+ 'kernel';
+ return d.vec4f(color.$, 1);
+ },
+ });
+
+ return (
+
+ );
+}
+
+let randomizeColor: () => void;
+
function App() {
+ const [currentColor, setCurrentColor] = useState(d.vec3f(1, 0, 0));
const time = useUniformValue(d.f32, 0);
+ randomizeColor = () => {
+ setCurrentColor(d.vec3f(Math.random(), Math.random(), Math.random()));
+ };
+
useFrame(() => {
time.value = performance.now() / 1000;
});
@@ -14,13 +47,15 @@ function App() {
'kernel';
const t = time.$;
const rgb = hsvToRgb(d.vec3f(t * 0.5, 1, 1));
+
return d.vec4f(rgb, 1);
},
});
return (
-
+
+
);
}
@@ -28,11 +63,20 @@ function App() {
// #region Example controls and cleanup
import { createRoot } from 'react-dom/client';
+import { useState } from 'react';
const reactRoot = createRoot(
document.getElementById('example-app') as HTMLDivElement,
);
reactRoot.render();
+export const controls = {
+ 'Randomize box color': {
+ onButtonClick: () => {
+ randomizeColor();
+ },
+ },
+};
+
export function onCleanup() {
setTimeout(() => reactRoot.unmount(), 0);
}
diff --git a/packages/typegpu-react/package.json b/packages/typegpu-react/package.json
index 31f277437..50bbd347d 100644
--- a/packages/typegpu-react/package.json
+++ b/packages/typegpu-react/package.json
@@ -30,13 +30,16 @@
"keywords": [],
"license": "MIT",
"peerDependencies": {
- "typegpu": "^0.7.0",
- "react": "^19.0.0"
+ "react": "^19.0.0",
+ "typegpu": "^0.7.0"
},
"devDependencies": {
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.0",
"@typegpu/tgpu-dev-cli": "workspace:*",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
"@webgpu/types": "catalog:types",
- "@types/react": "^19.0.0",
"tsdown": "catalog:build",
"typegpu": "workspace:*",
"typescript": "catalog:types",
diff --git a/packages/typegpu-react/src/index.ts b/packages/typegpu-react/src/index.ts
index f92b8da8f..5d1e878c4 100644
--- a/packages/typegpu-react/src/index.ts
+++ b/packages/typegpu-react/src/index.ts
@@ -1,3 +1,4 @@
export { useFrame } from './use-frame.ts';
export { useRender } from './use-render.ts';
export { useUniformValue } from './use-uniform-value.ts';
+export { useMirroredUniform } from './use-mirrored-uniform.ts';
diff --git a/packages/typegpu-react/src/use-mirrored-uniform.ts b/packages/typegpu-react/src/use-mirrored-uniform.ts
new file mode 100644
index 000000000..c2493be04
--- /dev/null
+++ b/packages/typegpu-react/src/use-mirrored-uniform.ts
@@ -0,0 +1,69 @@
+import * as d from 'typegpu/data';
+import { useRoot } from './root-context.tsx';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { ValidateUniformSchema } from 'typegpu';
+
+interface MirroredValue {
+ schema: TSchema;
+ readonly $: d.InferGPU;
+}
+
+export function useMirroredUniform<
+ TSchema extends d.AnyWgslData,
+ TValue extends d.Infer,
+>(
+ schema: ValidateUniformSchema,
+ value: TValue,
+): MirroredValue {
+ const root = useRoot();
+ const [uniformBuffer, setUniformBuffer] = useState(() => {
+ return root.createUniform(schema, value);
+ });
+ const prevSchemaRef = useRef(schema);
+ const currentSchemaRef = useRef(schema);
+ const cleanupRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (cleanupRef.current) {
+ clearTimeout(cleanupRef.current);
+ }
+
+ return () => {
+ cleanupRef.current = setTimeout(() => {
+ uniformBuffer.buffer.destroy();
+ }, 200);
+ };
+ }, [uniformBuffer]);
+
+ useEffect(() => {
+ if (!d.deepEqual(prevSchemaRef.current as d.AnyData, schema as d.AnyData)) {
+ uniformBuffer.buffer.destroy();
+ setUniformBuffer(root.createUniform(schema, value));
+ prevSchemaRef.current = schema;
+ } else {
+ uniformBuffer.write(value);
+ }
+ }, [schema, value, root, uniformBuffer]);
+
+ if (
+ !d.deepEqual(currentSchemaRef.current as d.AnyData, schema as d.AnyData)
+ ) {
+ currentSchemaRef.current = schema;
+ }
+
+ // Using current schema ref instead of schema directly
+ // to prevent unnecessary re-memoization when schema object
+ // reference changes but content is structurally equivalent.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: This value needs to be stable
+ const mirroredValue = useMemo(
+ () => ({
+ schema,
+ get $() {
+ return uniformBuffer.$;
+ },
+ }),
+ [currentSchemaRef.current, uniformBuffer],
+ );
+
+ return mirroredValue as MirroredValue;
+}
diff --git a/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx
new file mode 100644
index 000000000..4ee6a762c
--- /dev/null
+++ b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx
@@ -0,0 +1,329 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as d from 'typegpu/data';
+import { useMirroredUniform } from '../src/use-mirrored-uniform.ts';
+import * as rootContext from '../src/root-context.tsx';
+import type { TgpuRoot } from 'typegpu';
+import React from 'react';
+
+const createUniformMock = vi.fn();
+const writeMock = vi.fn();
+const destroyMock = vi.fn();
+
+const mockUniformBuffer = {
+ write: writeMock,
+ buffer: {
+ destroy: destroyMock,
+ },
+ $: {},
+};
+
+vi.spyOn(rootContext, 'useRoot').mockImplementation(
+ () =>
+ ({
+ createUniform: createUniformMock.mockImplementation(
+ () => mockUniformBuffer,
+ ),
+ }) as Partial as TgpuRoot,
+);
+
+describe('useMirroredUniform', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ createUniformMock.mockClear();
+ writeMock.mockClear();
+ destroyMock.mockClear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('Basic functionality', () => {
+ it('should create a uniform buffer on initial render', () => {
+ const schema = d.f32;
+ const value = 1.0;
+ renderHook(() => useMirroredUniform(schema, value));
+ expect(createUniformMock).toHaveBeenCalledTimes(1);
+ expect(createUniformMock).toHaveBeenCalledWith(schema, value);
+ });
+
+ it('should not recreate the buffer when the value changes but the schema is the same', () => {
+ const schema = d.f32;
+ const { rerender } = renderHook(
+ ({ value }: { value: number }) => useMirroredUniform(schema, value),
+ {
+ initialProps: { value: 1.0 },
+ },
+ );
+
+ expect(createUniformMock).toHaveBeenCalledTimes(1);
+
+ rerender({ value: 2.0 });
+
+ expect(createUniformMock).toHaveBeenCalledTimes(1);
+ expect(writeMock).toHaveBeenCalledWith(2.0);
+ });
+ });
+
+ describe('Schema change handling', () => {
+ it('should recreate the buffer when the schema changes', () => {
+ const { rerender } = renderHook(
+ ({
+ schema,
+ value,
+ }: {
+ schema: d.AnyWgslData;
+ value: d.Infer;
+ }) => useMirroredUniform(schema, value),
+ {
+ initialProps: { schema: d.f32, value: 1.0 } as {
+ schema: d.AnyWgslData;
+ value: d.Infer;
+ },
+ },
+ );
+
+ expect(createUniformMock).toHaveBeenCalledTimes(1);
+
+ rerender({ schema: d.vec2f, value: d.vec2f(1, 2) });
+
+ expect(createUniformMock).toHaveBeenCalledTimes(2);
+ expect(destroyMock).toHaveBeenCalledTimes(1);
+ expect(createUniformMock).toHaveBeenCalledWith(d.vec2f, d.vec2f(1, 2));
+ });
+
+ it('should not recreate the buffer for deeply equal schemas', () => {
+ const schema1 = d.struct({ a: d.f32 });
+ const schema2 = d.struct({ a: d.f32 });
+
+ const { rerender } = renderHook(
+ ({
+ schema,
+ value,
+ }: {
+ schema: d.AnyWgslData;
+ value: d.Infer;
+ }) => useMirroredUniform(schema, value),
+ {
+ initialProps: { schema: schema1, value: { a: 1.0 } },
+ },
+ );
+
+ expect(createUniformMock).toHaveBeenCalledTimes(1);
+
+ rerender({ schema: schema2, value: { a: 2.0 } });
+
+ expect(createUniformMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Memoization stability', () => {
+ it('should maintain stable memoized value when schema reference changes but content is identical', () => {
+ const schema1 = d.struct({ a: d.f32 });
+ const schema2 = d.struct({ a: d.f32 });
+
+ const { result, rerender } = renderHook(
+ ({ schema, value }) => useMirroredUniform(schema, value),
+ {
+ initialProps: { schema: schema1, value: { a: 1.0 } },
+ },
+ );
+
+ const firstResult = result.current;
+
+ rerender({ schema: schema2, value: { a: 1.0 } });
+
+ expect(result.current).toBe(firstResult);
+ expect(createUniformMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should update memoized value when schema content actually changes', () => {
+ const schema1 = d.struct({ a: d.f32 });
+ const schema2 = d.struct({ a: d.f32, b: d.f32 });
+
+ const { result, rerender } = renderHook(
+ ({
+ schema,
+ value,
+ }: {
+ schema: d.AnyWgslData;
+ value: d.Infer;
+ }) => useMirroredUniform(schema, value),
+ {
+ initialProps: { schema: schema1, value: { a: 1.0 } } as {
+ schema: d.AnyWgslData;
+ value: d.Infer;
+ },
+ },
+ );
+
+ const firstResult = result.current;
+
+ rerender({ schema: schema2, value: { a: 1.0, b: 2.0 } });
+
+ expect(result.current).not.toBe(firstResult);
+ expect(createUniformMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('should use currentSchemaRef in returned schema property', () => {
+ const schema1 = d.struct({ a: d.f32 });
+ const schema2 = d.struct({ a: d.f32 });
+
+ const { result, rerender } = renderHook(
+ ({ schema, value }) => useMirroredUniform(schema, value),
+ {
+ initialProps: { schema: schema1, value: { a: 1.0 } },
+ },
+ );
+
+ const initialSchema = result.current.schema;
+
+ rerender({ schema: schema2, value: { a: 1.0 } });
+
+ expect(result.current.schema).toBe(initialSchema);
+ expect(result.current.schema).toBe(schema1);
+ expect(result.current.schema).not.toBe(schema2);
+ });
+ });
+
+ describe('React StrictMode compatibility', () => {
+ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ );
+ const StrictModeWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+
+ it('should handle buffer creation in normal mode', () => {
+ const { result } = renderHook(
+ () => useMirroredUniform(d.f32, 1.0),
+ {
+ wrapper: TestWrapper,
+ },
+ );
+
+ expect({
+ createUniformCallCount: createUniformMock.mock.calls.length,
+ writeCallCount: writeMock.mock.calls.length,
+ result: result.current,
+ }).toMatchInlineSnapshot(`
+ {
+ "createUniformCallCount": 1,
+ "result": {
+ "$": {},
+ "schema": [Function],
+ },
+ "writeCallCount": 1,
+ }
+ `);
+ });
+
+ it('should handle buffer creation in StrictMode', () => {
+ const { result } = renderHook(
+ () => useMirroredUniform(d.f32, 1.0),
+ {
+ wrapper: StrictModeWrapper,
+ },
+ );
+
+ expect({
+ createUniformCallCount: createUniformMock.mock.calls.length,
+ writeCallCount: writeMock.mock.calls.length,
+ destroyCallCount: destroyMock.mock.calls.length,
+ result: result.current,
+ }).toMatchInlineSnapshot(`
+ {
+ "createUniformCallCount": 2,
+ "destroyCallCount": 0,
+ "result": {
+ "$": {},
+ "schema": [Function],
+ },
+ "writeCallCount": 1,
+ }
+ `);
+ });
+
+ it('should handle value updates in StrictMode', () => {
+ let value = 1.0;
+ const { rerender } = renderHook(
+ () => useMirroredUniform(d.f32, value),
+ {
+ wrapper: StrictModeWrapper,
+ },
+ );
+
+ const initialState = {
+ createUniformCallCount: createUniformMock.mock.calls.length,
+ writeCallCount: writeMock.mock.calls.length,
+ };
+
+ value = 2.0;
+ rerender();
+
+ expect({
+ initial: initialState,
+ afterUpdate: {
+ createUniformCallCount: createUniformMock.mock.calls.length,
+ writeCallCount: writeMock.mock.calls.length,
+ },
+ bufferNotRecreated: initialState.createUniformCallCount ===
+ createUniformMock.mock.calls.length,
+ }).toMatchInlineSnapshot(`
+ {
+ "afterUpdate": {
+ "createUniformCallCount": 2,
+ "writeCallCount": 2,
+ },
+ "bufferNotRecreated": true,
+ "initial": {
+ "createUniformCallCount": 2,
+ "writeCallCount": 1,
+ },
+ }
+ `);
+ });
+
+ it('should handle cleanup timeouts in StrictMode', async () => {
+ const { unmount } = renderHook(
+ () => useMirroredUniform(d.f32, 1.0),
+ {
+ wrapper: StrictModeWrapper,
+ },
+ );
+
+ const preUnmountState = {
+ destroyCallCount: destroyMock.mock.calls.length,
+ };
+
+ unmount();
+
+ const postUnmountPreTimeout = {
+ destroyCallCount: destroyMock.mock.calls.length,
+ };
+
+ vi.runAllTimers();
+
+ expect({
+ preUnmount: preUnmountState,
+ postUnmountPreTimeout,
+ afterTimeout: {
+ destroyCallCount: destroyMock.mock.calls.length,
+ },
+ }).toMatchInlineSnapshot(`
+ {
+ "afterTimeout": {
+ "destroyCallCount": 1,
+ },
+ "postUnmountPreTimeout": {
+ "destroyCallCount": 0,
+ },
+ "preUnmount": {
+ "destroyCallCount": 0,
+ },
+ }
+ `);
+ });
+ });
+});
diff --git a/packages/typegpu-react/tsconfig.json b/packages/typegpu-react/tsconfig.json
index d503ef3ff..3f392dc2d 100644
--- a/packages/typegpu-react/tsconfig.json
+++ b/packages/typegpu-react/tsconfig.json
@@ -3,6 +3,6 @@
"compilerOptions": {
"jsx": "react-jsx"
},
- "include": ["src/**/*"],
+ "include": ["src/**/*", "tests/**/*", "vitest.config.mts"],
"exclude": ["node_modules", "dist"]
}
diff --git a/packages/typegpu-react/vitest.config.mts b/packages/typegpu-react/vitest.config.mts
new file mode 100644
index 000000000..527089358
--- /dev/null
+++ b/packages/typegpu-react/vitest.config.mts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ },
+});
diff --git a/packages/typegpu/src/data/deepEqual.ts b/packages/typegpu/src/data/deepEqual.ts
new file mode 100644
index 000000000..bd163497e
--- /dev/null
+++ b/packages/typegpu/src/data/deepEqual.ts
@@ -0,0 +1,125 @@
+import type { AnyAttribute } from './attributes.ts';
+import {
+ isDisarray,
+ isLooseData,
+ isLooseDecorated,
+ isUnstruct,
+} from './dataTypes.ts';
+import type { AnyData } from './dataTypes.ts';
+import {
+ isAtomic,
+ isDecorated,
+ isPtr,
+ isWgslArray,
+ isWgslStruct,
+} from './wgslTypes.ts';
+
+/**
+ * Performs a deep comparison of two TypeGPU data schemas.
+ *
+ * @param a The first data schema to compare.
+ * @param b The second data schema to compare.
+ * @returns `true` if the schemas are deeply equal, `false` otherwise.
+ *
+ * @example
+ * ```ts
+ * import { vec3f, struct, deepEqual } from 'typegpu/data';
+ *
+ * const schema1 = struct({ a: vec3f });
+ * const schema2 = struct({ a: vec3f });
+ * const schema3 = struct({ b: vec3f });
+ *
+ * console.log(deepEqual(schema1, schema2)); // true
+ * console.log(deepEqual(schema1, schema3)); // false
+ * ```
+ */
+export function deepEqual(a: AnyData, b: AnyData): boolean {
+ if (a === b) {
+ return true;
+ }
+
+ if (a.type !== b.type) {
+ return false;
+ }
+
+ if (
+ (isWgslStruct(a) && isWgslStruct(b)) ||
+ (isUnstruct(a) && isUnstruct(b))
+ ) {
+ const aProps = a.propTypes;
+ const bProps = b.propTypes;
+ const aKeys = Object.keys(aProps);
+ const bKeys = Object.keys(bProps);
+
+ if (aKeys.length !== bKeys.length) {
+ return false;
+ }
+
+ for (let i = 0; i < aKeys.length; i++) {
+ const keyA = aKeys[i];
+ const keyB = bKeys[i];
+ if (
+ keyA !== keyB || !keyA || !keyB ||
+ !deepEqual(aProps[keyA], bProps[keyB])
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ if ((isWgslArray(a) && isWgslArray(b)) || (isDisarray(a) && isDisarray(b))) {
+ return (
+ a.elementCount === b.elementCount &&
+ deepEqual(a.elementType as AnyData, b.elementType as AnyData)
+ );
+ }
+
+ if (isPtr(a) && isPtr(b)) {
+ return (
+ a.addressSpace === b.addressSpace &&
+ a.access === b.access &&
+ deepEqual(a.inner as AnyData, b.inner as AnyData)
+ );
+ }
+
+ if (isAtomic(a) && isAtomic(b)) {
+ return deepEqual(a.inner as AnyData, b.inner as AnyData);
+ }
+
+ if (
+ (isDecorated(a) && isDecorated(b)) ||
+ (isLooseDecorated(a) && isLooseDecorated(b))
+ ) {
+ if (!deepEqual(a.inner as AnyData, b.inner as AnyData)) {
+ return false;
+ }
+ if (a.attribs.length !== b.attribs.length) {
+ return false;
+ }
+
+ // Create comparable string representations for each attribute
+ const getAttrKey = (attr: unknown): string => {
+ const anyAttr = attr as AnyAttribute;
+ return `${anyAttr.type}(${(anyAttr.params ?? []).join(',')})`;
+ };
+
+ const attrsA = a.attribs.map(getAttrKey);
+ const attrsB = b.attribs.map(getAttrKey);
+
+ for (let i = 0; i < attrsA.length; i++) {
+ if (attrsA[i] !== attrsB[i]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ if (isLooseData(a) && isLooseData(b)) {
+ // TODO: This is a simplified check. A a more detailed comparison might be necessary.
+ return JSON.stringify(a) === JSON.stringify(b);
+ }
+
+ return true;
+}
diff --git a/packages/typegpu/src/data/index.ts b/packages/typegpu/src/data/index.ts
index 6c83224bc..054d3458c 100644
--- a/packages/typegpu/src/data/index.ts
+++ b/packages/typegpu/src/data/index.ts
@@ -166,6 +166,7 @@ export {
export { PUBLIC_sizeOf as sizeOf } from './sizeOf.ts';
export { PUBLIC_alignmentOf as alignmentOf } from './alignmentOf.ts';
export { builtin } from '../builtin.ts';
+export { deepEqual } from './deepEqual.ts';
export type {
AnyBuiltin,
BuiltinClipDistances,
diff --git a/packages/typegpu/tests/data/deepEqual.test.ts b/packages/typegpu/tests/data/deepEqual.test.ts
new file mode 100644
index 000000000..8457168a9
--- /dev/null
+++ b/packages/typegpu/tests/data/deepEqual.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from 'vitest';
+import {
+ align,
+ arrayOf,
+ atomic,
+ deepEqual,
+ disarrayOf,
+ f16,
+ f32,
+ i32,
+ location,
+ mat2x2f,
+ mat3x3f,
+ size,
+ struct,
+ u32,
+ unstruct,
+ vec2f,
+ vec2u,
+ vec3f,
+} from '../../src/data/index.ts';
+import { ptrPrivate, ptrStorage, ptrWorkgroup } from '../../src/data/ptr.ts';
+
+describe('deepEqual', () => {
+ it('compares simple types', () => {
+ expect(deepEqual(f32, f32)).toBe(true);
+ expect(deepEqual(u32, u32)).toBe(true);
+ expect(deepEqual(f32, u32)).toBe(false);
+ expect(deepEqual(f32, f16)).toBe(false);
+ });
+
+ it('compares vector types', () => {
+ expect(deepEqual(vec2f, vec2f)).toBe(true);
+ expect(deepEqual(vec3f, vec3f)).toBe(true);
+ expect(deepEqual(vec2f, vec3f)).toBe(false);
+ expect(deepEqual(vec2f, vec2u)).toBe(false);
+ });
+
+ it('compares matrix types', () => {
+ expect(deepEqual(mat2x2f, mat2x2f)).toBe(true);
+ expect(deepEqual(mat3x3f, mat3x3f)).toBe(true);
+ expect(deepEqual(mat2x2f, mat3x3f)).toBe(false);
+ });
+
+ it('compares struct types', () => {
+ const struct1 = struct({ a: f32, b: vec2u });
+ const struct2 = struct({ a: f32, b: vec2u });
+ const struct3 = struct({ b: vec2u, a: f32 }); // different order
+ const struct4 = struct({ a: u32, b: vec2u }); // different prop type
+ const struct5 = struct({ a: f32, c: vec2u }); // different prop name
+ const struct6 = struct({ a: f32 }); // different number of props
+
+ expect(deepEqual(struct1, struct2)).toBe(true);
+ expect(deepEqual(struct1, struct3)).toBe(false); // property order should matter
+ expect(deepEqual(struct1, struct4)).toBe(false);
+ expect(deepEqual(struct1, struct5)).toBe(false);
+ expect(deepEqual(struct1, struct6)).toBe(false);
+ });
+
+ it('compares nested struct types', () => {
+ const nested1 = struct({ c: i32 });
+ const nested2 = struct({ c: i32 });
+ const nested3 = struct({ c: u32 });
+
+ const struct1 = struct({ a: f32, b: nested1 });
+ const struct2 = struct({ a: f32, b: nested2 });
+ const struct3 = struct({ a: f32, b: nested3 });
+
+ expect(deepEqual(struct1, struct2)).toBe(true);
+ expect(deepEqual(struct1, struct3)).toBe(false);
+ });
+
+ it('compares array types', () => {
+ const array1 = arrayOf(f32, 4);
+ const array2 = arrayOf(f32, 4);
+ const array3 = arrayOf(u32, 4);
+ const array4 = arrayOf(f32, 5);
+
+ expect(deepEqual(array1, array2)).toBe(true);
+ expect(deepEqual(array1, array3)).toBe(false);
+ expect(deepEqual(array1, array4)).toBe(false);
+ });
+
+ it('compares arrays of structs', () => {
+ const struct1 = struct({ a: f32 });
+ const struct2 = struct({ a: f32 });
+ const struct3 = struct({ a: u32 });
+
+ const array1 = arrayOf(struct1, 2);
+ const array2 = arrayOf(struct2, 2);
+ const array3 = arrayOf(struct3, 2);
+
+ expect(deepEqual(array1, array2)).toBe(true);
+ expect(deepEqual(array1, array3)).toBe(false);
+ });
+
+ it('compares decorated types', () => {
+ const decorated1 = align(16, f32);
+ const decorated2 = align(16, f32);
+ const decorated3 = align(8, f32);
+ const decorated4 = align(16, u32);
+ const decorated5 = location(0, f32);
+ const decorated6 = size(8, align(16, u32));
+ const decorated7 = align(16, size(8, u32));
+
+ expect(deepEqual(decorated1, decorated2)).toBe(true);
+ expect(deepEqual(decorated1, decorated3)).toBe(false);
+ expect(deepEqual(decorated1, decorated4)).toBe(false);
+ expect(deepEqual(decorated1, decorated5)).toBe(false);
+ expect(deepEqual(decorated6, decorated7)).toBe(false); // decorator order should matter
+ });
+
+ it('compares pointer types', () => {
+ const ptr1 = ptrPrivate(f32);
+ const ptr2 = ptrPrivate(f32);
+ const ptr3 = ptrWorkgroup(f32);
+ const ptr4 = ptrPrivate(u32);
+ const ptr5 = ptrStorage(f32, 'read');
+ const ptr6 = ptrStorage(f32, 'read-write');
+
+ expect(deepEqual(ptr1, ptr2)).toBe(true);
+ expect(deepEqual(ptr1, ptr3)).toBe(false);
+ expect(deepEqual(ptr1, ptr4)).toBe(false);
+ expect(deepEqual(ptr5, ptr6)).toBe(false);
+ expect(deepEqual(ptrStorage(f32, 'read'), ptrStorage(f32, 'read'))).toBe(
+ true,
+ );
+ });
+
+ it('compares atomic types', () => {
+ const atomic1 = atomic(u32);
+ const atomic2 = atomic(u32);
+ const atomic3 = atomic(i32);
+
+ expect(deepEqual(atomic1, atomic2)).toBe(true);
+ expect(deepEqual(atomic1, atomic3)).toBe(false);
+ });
+
+ it('compares loose decorated types', () => {
+ const decorated1 = align(16, unstruct({ a: f32 }));
+ const decorated2 = align(16, unstruct({ a: f32 }));
+ const decorated3 = align(8, unstruct({ a: f32 }));
+ const decorated4 = align(16, unstruct({ a: u32 }));
+ const decorated5 = location(0, unstruct({ a: f32 }));
+
+ expect(deepEqual(decorated1, decorated2)).toBe(true);
+ expect(deepEqual(decorated1, decorated3)).toBe(false);
+ expect(deepEqual(decorated1, decorated4)).toBe(false);
+ expect(deepEqual(decorated1, decorated5)).toBe(false);
+ });
+
+ it('compares loose data types', () => {
+ const unstruct1 = unstruct({ a: f32 });
+ const unstruct2 = unstruct({ a: f32 });
+ const unstruct3 = unstruct({ b: f32 });
+
+ const disarray1 = disarrayOf(u32, 4);
+ const disarray2 = disarrayOf(u32, 4);
+ const disarray3 = disarrayOf(u32, 5);
+
+ expect(deepEqual(unstruct1, unstruct2)).toBe(true);
+ expect(deepEqual(unstruct1, unstruct3)).toBe(false);
+ expect(deepEqual(disarray1, disarray2)).toBe(true);
+ expect(deepEqual(disarray1, disarray3)).toBe(false);
+ });
+
+ it('compares different kinds of types', () => {
+ expect(deepEqual(f32, vec2f)).toBe(false);
+ expect(deepEqual(struct({ a: f32 }), unstruct({ a: f32 }))).toBe(false);
+ expect(deepEqual(arrayOf(f32, 4), disarrayOf(f32, 4))).toBe(false);
+ expect(deepEqual(struct({ a: f32 }), f32)).toBe(false);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b279ce343..415fa3728 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -109,7 +109,7 @@ importers:
devDependencies:
'@types/bun':
specifier: latest
- version: 1.2.21(@types/react@19.1.8)
+ version: 1.2.22(@types/react@19.1.8)
apps/infra-benchmarks:
devDependencies:
@@ -521,12 +521,21 @@ importers:
specifier: ^19.0.0
version: 19.1.0
devDependencies:
+ '@testing-library/dom':
+ specifier: ^10.4.1
+ version: 10.4.1
+ '@testing-library/react':
+ specifier: ^16.3.0
+ version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@typegpu/tgpu-dev-cli':
specifier: workspace:*
version: link:../tgpu-dev-cli
'@types/react':
- specifier: ^19.0.0
+ specifier: ^19.1.8
version: 19.1.8
+ '@types/react-dom':
+ specifier: ^19.1.6
+ version: 19.1.6(@types/react@19.1.8)
'@webgpu/types':
specifier: catalog:types
version: 0.1.63
@@ -2653,6 +2662,25 @@ packages:
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/react@16.3.0':
+ resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
@@ -2690,8 +2718,8 @@ packages:
'@types/bun@1.2.19':
resolution: {integrity: sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg==}
- '@types/bun@1.2.21':
- resolution: {integrity: sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A==}
+ '@types/bun@1.2.22':
+ resolution: {integrity: sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA==}
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
@@ -3094,8 +3122,8 @@ packages:
peerDependencies:
'@types/react': ^19
- bun-types@1.2.21:
- resolution: {integrity: sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw==}
+ bun-types@1.2.22:
+ resolution: {integrity: sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA==}
peerDependencies:
'@types/react': ^19
@@ -8398,6 +8426,27 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.27.1
+ '@babel/runtime': 7.26.9
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@babel/runtime': 7.26.9
+ '@testing-library/dom': 10.4.1
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.8
+ '@types/react-dom': 19.1.6(@types/react@19.1.8)
+
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)':
dependencies:
'@testing-library/dom': 10.4.0
@@ -8451,9 +8500,9 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
- '@types/bun@1.2.21(@types/react@19.1.8)':
+ '@types/bun@1.2.22(@types/react@19.1.8)':
dependencies:
- bun-types: 1.2.21(@types/react@19.1.8)
+ bun-types: 1.2.22(@types/react@19.1.8)
transitivePeerDependencies:
- '@types/react'
@@ -9045,7 +9094,7 @@ snapshots:
'@types/node': 24.3.0
'@types/react': 19.1.8
- bun-types@1.2.21(@types/react@19.1.8):
+ bun-types@1.2.22(@types/react@19.1.8):
dependencies:
'@types/node': 24.3.0
'@types/react': 19.1.8