From 46429e29bda90631494c50aff5858dd57a63778c Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Tue, 23 Sep 2025 18:06:30 +0200 Subject: [PATCH 01/18] Add useMirroredUniform hook --- packages/typegpu-react/src/index.ts | 1 + .../typegpu-react/src/use-mirrored-uniform.ts | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 packages/typegpu-react/src/use-mirrored-uniform.ts 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..e4cb8986c --- /dev/null +++ b/packages/typegpu-react/src/use-mirrored-uniform.ts @@ -0,0 +1,50 @@ +import type * 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] = useState(() => { + return root.createUniform(schema, value); + }); + + useEffect(() => { + uniformBuffer.write(value); + }, [value, uniformBuffer]); + + const cleanupRef = useRef | null>(null); + useEffect(() => { + if (cleanupRef.current) { + clearTimeout(cleanupRef.current); + } + + return () => { + cleanupRef.current = setTimeout(() => { + uniformBuffer.buffer.destroy(); + }, 200); + }; + }, [uniformBuffer]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: This value needs to be stable + const mirroredValue = useMemo(() => ({ + schema, + get $() { + return uniformBuffer.$; + }, + }), []); + + return mirroredValue as MirroredValue; +} From d7f13f9b5a6fc6c43d851bb286b9c697e645cbc8 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Tue, 23 Sep 2025 18:06:56 +0200 Subject: [PATCH 02/18] Add useMirroredUniform example usage --- .../src/examples/react/triangle/index.tsx | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) 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); } From e3c6f06d7d3438615121cf3e4d95d2c2a3f52094 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 17 Sep 2025 14:22:58 +0200 Subject: [PATCH 03/18] Add deepEqual function --- packages/typegpu/src/data/deepEqual.ts | 99 ++++++++++++++++++++++++++ packages/typegpu/src/data/index.ts | 1 + 2 files changed, 100 insertions(+) create mode 100644 packages/typegpu/src/data/deepEqual.ts diff --git a/packages/typegpu/src/data/deepEqual.ts b/packages/typegpu/src/data/deepEqual.ts new file mode 100644 index 000000000..4562857ac --- /dev/null +++ b/packages/typegpu/src/data/deepEqual.ts @@ -0,0 +1,99 @@ +import type { AnyAttribute } from './attributes.ts'; +import { isLooseData, isUnstruct } from './dataTypes.ts'; +import type { AnyData } from './dataTypes.ts'; +import { isDecorated, 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)) { + 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; + } + + aKeys.sort(); + bKeys.sort(); + + if (aKeys.join() !== bKeys.join()) { + return false; + } + + for (const key of aKeys) { + if (!deepEqual(aProps[key], bProps[key])) { + return false; + } + } + return true; + } + + if (isWgslArray(a) && isWgslArray(b)) { + return ( + a.elementCount === b.elementCount && + deepEqual(a.elementType as AnyData, b.elementType as AnyData) + ); + } + + if (isDecorated(a) && isDecorated(b)) { + if (!deepEqual(a.inner as AnyData, b.inner as AnyData)) { + return false; + } + if (a.attribs.length !== b.attribs.length) { + return false; + } + // TODO: A more robust comparison might be needed if attribute order is not guaranteed. + // For now, assuming attributes are ordered. + for (let i = 0; i < a.attribs.length; i++) { + const attrA = a.attribs[i] as AnyAttribute; + const attrB = b.attribs[i] as AnyAttribute; + if (attrA.type !== attrB.type) return false; + if (attrA.params?.length !== attrB.params?.length) return false; + if (attrA.params) { + for (let j = 0; j < attrA.params.length; j++) { + if (attrA.params[j] !== attrB.params[j]) return false; + } + } + } + return true; + } + + if (isUnstruct(a) && isUnstruct(b)) { + return deepEqual(a, b); + } + + 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, From da1b70e0e509c845214eb69733b3e992ffd9a8b0 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 17 Sep 2025 15:48:16 +0200 Subject: [PATCH 04/18] Fix unstructs comparing --- packages/typegpu/src/data/deepEqual.ts | 30 ++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/typegpu/src/data/deepEqual.ts b/packages/typegpu/src/data/deepEqual.ts index 4562857ac..3d55eec8b 100644 --- a/packages/typegpu/src/data/deepEqual.ts +++ b/packages/typegpu/src/data/deepEqual.ts @@ -1,5 +1,5 @@ import type { AnyAttribute } from './attributes.ts'; -import { isLooseData, isUnstruct } from './dataTypes.ts'; +import { isDisarray, isLooseData, isUnstruct } from './dataTypes.ts'; import type { AnyData } from './dataTypes.ts'; import { isDecorated, isWgslArray, isWgslStruct } from './wgslTypes.ts'; @@ -87,7 +87,33 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { } if (isUnstruct(a) && isUnstruct(b)) { - return deepEqual(a, 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 unstructs, order of properties matters. + if (aKeys.join() !== bKeys.join()) { + return false; + } + + for (const key of aKeys) { + if (!deepEqual(aProps[key], bProps[key])) { + return false; + } + } + return true; + } + + if (isDisarray(a) && isDisarray(b)) { + return ( + a.elementCount === b.elementCount && + deepEqual(a.elementType as AnyData, b.elementType as AnyData) + ); } if (isLooseData(a) && isLooseData(b)) { From f60b573f56b68c9d98dd213a2bab759f5e1311b7 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 17 Sep 2025 15:48:37 +0200 Subject: [PATCH 05/18] Add unit tests --- packages/typegpu/tests/data/deepEqual.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 packages/typegpu/tests/data/deepEqual.test.ts diff --git a/packages/typegpu/tests/data/deepEqual.test.ts b/packages/typegpu/tests/data/deepEqual.test.ts new file mode 100644 index 000000000..fd2735977 --- /dev/null +++ b/packages/typegpu/tests/data/deepEqual.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { + deepEqual, + f32, + u32, + i32, + vec2f, + vec3f, + vec2u, + mat2x2f, + mat3x3f, + struct, + unstruct, + arrayOf, + disarrayOf, + align, + location, + f16, +} from '../../src/data/index.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(true); // property order shouldn't 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); + + 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); + }); +}); From 19edf291ce5c65582c3af6def640f95aa9569382 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 17 Sep 2025 15:49:24 +0200 Subject: [PATCH 06/18] Fix lint --- packages/typegpu/tests/data/deepEqual.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/typegpu/tests/data/deepEqual.test.ts b/packages/typegpu/tests/data/deepEqual.test.ts index fd2735977..d4060ad23 100644 --- a/packages/typegpu/tests/data/deepEqual.test.ts +++ b/packages/typegpu/tests/data/deepEqual.test.ts @@ -1,21 +1,21 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { + align, + arrayOf, deepEqual, + disarrayOf, + f16, f32, - u32, i32, - vec2f, - vec3f, - vec2u, + location, mat2x2f, mat3x3f, struct, + u32, unstruct, - arrayOf, - disarrayOf, - align, - location, - f16, + vec2f, + vec2u, + vec3f, } from '../../src/data/index.ts'; describe('deepEqual', () => { From cd7057b4d3af6ad1ce580dd4eac96dc91bb2916f Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 17 Sep 2025 19:09:37 +0200 Subject: [PATCH 07/18] Enhance deepEqual to support ptr and atomic types --- packages/typegpu/src/data/deepEqual.ts | 58 +++++++++++++++++++------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/typegpu/src/data/deepEqual.ts b/packages/typegpu/src/data/deepEqual.ts index 3d55eec8b..86b4c5d62 100644 --- a/packages/typegpu/src/data/deepEqual.ts +++ b/packages/typegpu/src/data/deepEqual.ts @@ -1,7 +1,18 @@ import type { AnyAttribute } from './attributes.ts'; -import { isDisarray, isLooseData, isUnstruct } from './dataTypes.ts'; +import { + isDisarray, + isLooseData, + isLooseDecorated, + isUnstruct, +} from './dataTypes.ts'; import type { AnyData } from './dataTypes.ts'; -import { isDecorated, isWgslArray, isWgslStruct } from './wgslTypes.ts'; +import { + isAtomic, + isDecorated, + isPtr, + isWgslArray, + isWgslStruct, +} from './wgslTypes.ts'; /** * Performs a deep comparison of two TypeGPU data schemas. @@ -63,26 +74,45 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { ); } - if (isDecorated(a) && isDecorated(b)) { + 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; } - // TODO: A more robust comparison might be needed if attribute order is not guaranteed. - // For now, assuming attributes are ordered. - for (let i = 0; i < a.attribs.length; i++) { - const attrA = a.attribs[i] as AnyAttribute; - const attrB = b.attribs[i] as AnyAttribute; - if (attrA.type !== attrB.type) return false; - if (attrA.params?.length !== attrB.params?.length) return false; - if (attrA.params) { - for (let j = 0; j < attrA.params.length; j++) { - if (attrA.params[j] !== attrB.params[j]) return false; - } + + // Create comparable string representations for each attribute and sort them + // to handle cases where attributes are in a different order. + const getAttrKey = (attr: unknown): string => { + const anyAttr = attr as AnyAttribute; + return `${anyAttr.type}(${(anyAttr.params ?? []).join(',')})`; + }; + + const sortedAttrsA = a.attribs.map(getAttrKey).sort(); + const sortedAttrsB = b.attribs.map(getAttrKey).sort(); + + for (let i = 0; i < sortedAttrsA.length; i++) { + if (sortedAttrsA[i] !== sortedAttrsB[i]) { + return false; } } + return true; } From 953960a5d697a7db9d5fe9f716138b853f668d4e Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 17 Sep 2025 19:24:33 +0200 Subject: [PATCH 08/18] Add more tests --- packages/typegpu/tests/data/deepEqual.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/typegpu/tests/data/deepEqual.test.ts b/packages/typegpu/tests/data/deepEqual.test.ts index d4060ad23..a0e09f23d 100644 --- a/packages/typegpu/tests/data/deepEqual.test.ts +++ b/packages/typegpu/tests/data/deepEqual.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { align, arrayOf, + atomic, deepEqual, disarrayOf, f16, @@ -17,6 +18,7 @@ import { vec2u, vec3f, } from '../../src/data/index.ts'; +import { ptrPrivate, ptrStorage, ptrWorkgroup } from '../../src/data/ptr.ts'; describe('deepEqual', () => { it('compares simple types', () => { @@ -104,6 +106,45 @@ describe('deepEqual', () => { expect(deepEqual(decorated1, decorated5)).toBe(false); }); + 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 }); From 87923c72ad95ed3ea64e3420b110df87eeacb0f2 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Thu, 18 Sep 2025 16:39:59 +0200 Subject: [PATCH 09/18] Remove unnecessary key sorting --- packages/typegpu/src/data/deepEqual.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/typegpu/src/data/deepEqual.ts b/packages/typegpu/src/data/deepEqual.ts index 86b4c5d62..2acec1b97 100644 --- a/packages/typegpu/src/data/deepEqual.ts +++ b/packages/typegpu/src/data/deepEqual.ts @@ -52,13 +52,6 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { return false; } - aKeys.sort(); - bKeys.sort(); - - if (aKeys.join() !== bKeys.join()) { - return false; - } - for (const key of aKeys) { if (!deepEqual(aProps[key], bProps[key])) { return false; @@ -104,11 +97,11 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { return `${anyAttr.type}(${(anyAttr.params ?? []).join(',')})`; }; - const sortedAttrsA = a.attribs.map(getAttrKey).sort(); - const sortedAttrsB = b.attribs.map(getAttrKey).sort(); + const attrsA = a.attribs.map(getAttrKey); + const attrsB = b.attribs.map(getAttrKey); - for (let i = 0; i < sortedAttrsA.length; i++) { - if (sortedAttrsA[i] !== sortedAttrsB[i]) { + for (let i = 0; i < attrsA.length; i++) { + if (attrsA[i] !== attrsB[i]) { return false; } } @@ -126,11 +119,6 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { return false; } - // For unstructs, order of properties matters. - if (aKeys.join() !== bKeys.join()) { - return false; - } - for (const key of aKeys) { if (!deepEqual(aProps[key], bProps[key])) { return false; From 6a09603830497a267a70d41905797bbce3ce7537 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Thu, 18 Sep 2025 17:14:54 +0200 Subject: [PATCH 10/18] Refine struct comparison to consider property order --- packages/typegpu/src/data/deepEqual.ts | 41 ++++++------------- packages/typegpu/tests/data/deepEqual.test.ts | 2 +- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/packages/typegpu/src/data/deepEqual.ts b/packages/typegpu/src/data/deepEqual.ts index 2acec1b97..318ae8e67 100644 --- a/packages/typegpu/src/data/deepEqual.ts +++ b/packages/typegpu/src/data/deepEqual.ts @@ -42,7 +42,10 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { return false; } - if (isWgslStruct(a) && isWgslStruct(b)) { + if ( + (isWgslStruct(a) && isWgslStruct(b)) || + (isUnstruct(a) && isUnstruct(b)) + ) { const aProps = a.propTypes; const bProps = b.propTypes; const aKeys = Object.keys(aProps); @@ -52,15 +55,20 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { return false; } - for (const key of aKeys) { - if (!deepEqual(aProps[key], bProps[key])) { + 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)) { + if ((isWgslArray(a) && isWgslArray(b)) || (isDisarray(a) && isDisarray(b))) { return ( a.elementCount === b.elementCount && deepEqual(a.elementType as AnyData, b.elementType as AnyData) @@ -109,31 +117,6 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { return true; } - if (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 (const key of aKeys) { - if (!deepEqual(aProps[key], bProps[key])) { - return false; - } - } - return true; - } - - if (isDisarray(a) && isDisarray(b)) { - return ( - a.elementCount === b.elementCount && - deepEqual(a.elementType as AnyData, b.elementType as AnyData) - ); - } - 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); diff --git a/packages/typegpu/tests/data/deepEqual.test.ts b/packages/typegpu/tests/data/deepEqual.test.ts index a0e09f23d..78c20252b 100644 --- a/packages/typegpu/tests/data/deepEqual.test.ts +++ b/packages/typegpu/tests/data/deepEqual.test.ts @@ -50,7 +50,7 @@ describe('deepEqual', () => { const struct6 = struct({ a: f32 }); // different number of props expect(deepEqual(struct1, struct2)).toBe(true); - expect(deepEqual(struct1, struct3)).toBe(true); // property order shouldn't matter + 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); From 2dfadb34db2aac351c127e4a36fd356e4793aa2c Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Fri, 19 Sep 2025 14:17:18 +0200 Subject: [PATCH 11/18] Add test for decorator order --- packages/typegpu/src/data/deepEqual.ts | 3 +-- packages/typegpu/tests/data/deepEqual.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/typegpu/src/data/deepEqual.ts b/packages/typegpu/src/data/deepEqual.ts index 318ae8e67..bd163497e 100644 --- a/packages/typegpu/src/data/deepEqual.ts +++ b/packages/typegpu/src/data/deepEqual.ts @@ -98,8 +98,7 @@ export function deepEqual(a: AnyData, b: AnyData): boolean { return false; } - // Create comparable string representations for each attribute and sort them - // to handle cases where attributes are in a different order. + // Create comparable string representations for each attribute const getAttrKey = (attr: unknown): string => { const anyAttr = attr as AnyAttribute; return `${anyAttr.type}(${(anyAttr.params ?? []).join(',')})`; diff --git a/packages/typegpu/tests/data/deepEqual.test.ts b/packages/typegpu/tests/data/deepEqual.test.ts index 78c20252b..8457168a9 100644 --- a/packages/typegpu/tests/data/deepEqual.test.ts +++ b/packages/typegpu/tests/data/deepEqual.test.ts @@ -11,6 +11,7 @@ import { location, mat2x2f, mat3x3f, + size, struct, u32, unstruct, @@ -99,11 +100,14 @@ describe('deepEqual', () => { 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', () => { From fda0a582d6786dccfdf43fc3f290c15a716aac1b Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Tue, 23 Sep 2025 19:42:13 +0200 Subject: [PATCH 12/18] Fix uniform buffer recreation on schema change --- .../typegpu-react/src/use-mirrored-uniform.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/typegpu-react/src/use-mirrored-uniform.ts b/packages/typegpu-react/src/use-mirrored-uniform.ts index e4cb8986c..c408a189f 100644 --- a/packages/typegpu-react/src/use-mirrored-uniform.ts +++ b/packages/typegpu-react/src/use-mirrored-uniform.ts @@ -1,4 +1,4 @@ -import type * as d from 'typegpu/data'; +import * as d from 'typegpu/data'; import { useRoot } from './root-context.tsx'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { ValidateUniformSchema } from 'typegpu'; @@ -16,16 +16,24 @@ export function useMirroredUniform< value: TValue, ): MirroredValue { const root = useRoot(); - - const [uniformBuffer] = useState(() => { + const [uniformBuffer, setUniformBuffer] = useState(() => { return root.createUniform(schema, value); }); + const prevSchemaRef = useRef(schema); + const cleanupRef = useRef | null>(null); useEffect(() => { - uniformBuffer.write(value); - }, [value, uniformBuffer]); + let currentBuffer = uniformBuffer; + if (!d.deepEqual(prevSchemaRef.current as d.AnyData, schema as d.AnyData)) { + currentBuffer.buffer.destroy(); + currentBuffer = root.createUniform(schema, value); + setUniformBuffer(currentBuffer); + prevSchemaRef.current = schema; + } + + currentBuffer.write(value); + }, [schema, value, root, uniformBuffer]); - const cleanupRef = useRef | null>(null); useEffect(() => { if (cleanupRef.current) { clearTimeout(cleanupRef.current); @@ -38,13 +46,15 @@ export function useMirroredUniform< }; }, [uniformBuffer]); - // biome-ignore lint/correctness/useExhaustiveDependencies: This value needs to be stable - const mirroredValue = useMemo(() => ({ - schema, - get $() { - return uniformBuffer.$; - }, - }), []); + const mirroredValue = useMemo( + () => ({ + schema, + get $() { + return uniformBuffer.$; + }, + }), + [schema, uniformBuffer], + ); return mirroredValue as MirroredValue; } From 9c2f12aa18fb2ab4e23b3bd0eba9e2be5fcbd600 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 24 Sep 2025 11:20:12 +0200 Subject: [PATCH 13/18] Setup testing environment for typegpu-react --- packages/typegpu-react/package.json | 9 ++-- packages/typegpu-react/tsconfig.json | 2 +- packages/typegpu-react/vitest.config.mts | 8 +++ pnpm-lock.yaml | 67 ++++++++++++++++++++---- 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 packages/typegpu-react/vitest.config.mts 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/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/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 From 3794b12083021a626635ac88dbb2a7b7948549cb Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 24 Sep 2025 11:20:29 +0200 Subject: [PATCH 14/18] Add simple tests for useMirroredUniform hook --- .../tests/use-mirrored-uniform.test.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 packages/typegpu-react/tests/use-mirrored-uniform.test.tsx 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..e78879412 --- /dev/null +++ b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx @@ -0,0 +1,99 @@ +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'; + +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(); + }); + + 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 }) => useMirroredUniform(schema, value), + { + initialProps: { value: 1.0 }, + }, + ); + + expect(createUniformMock).toHaveBeenCalledTimes(1); + + rerender({ value: 2.0 }); + + expect(createUniformMock).toHaveBeenCalledTimes(1); + expect(writeMock).toHaveBeenCalledWith(2.0); + }); + + it('should recreate the buffer when the schema changes', () => { + const { rerender } = renderHook( + ({ schema, value }) => useMirroredUniform(schema, value), + { + initialProps: { schema: d.f32, value: 1.0 } as { + schema: d.AnyWgslData; + value: d.Infer; + }, + }, + ); + + expect(createUniformMock).toHaveBeenCalledTimes(1); + + // Rerender with a new schema + 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 }) => 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); + }); +}); From 34f88751f0c76f7e3c66b9cce840be1d93e13aeb Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Wed, 24 Sep 2025 11:44:09 +0200 Subject: [PATCH 15/18] Add StrictMode behavior tests for useMirroredUniform --- .../tests/use-mirrored-uniform.test.tsx | 170 +++++++++++++++++- 1 file changed, 164 insertions(+), 6 deletions(-) diff --git a/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx index e78879412..c9f3915e9 100644 --- a/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx +++ b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx @@ -4,6 +4,7 @@ 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(); @@ -17,9 +18,14 @@ const mockUniformBuffer = { $: {}, }; -vi.spyOn(rootContext, 'useRoot').mockImplementation(() => ({ - createUniform: createUniformMock.mockImplementation(() => mockUniformBuffer), -} as Partial as TgpuRoot)); +vi.spyOn(rootContext, 'useRoot').mockImplementation( + () => + ({ + createUniform: createUniformMock.mockImplementation( + () => mockUniformBuffer, + ), + }) as Partial as TgpuRoot, +); describe('useMirroredUniform', () => { beforeEach(() => { @@ -44,7 +50,7 @@ describe('useMirroredUniform', () => { it('should not recreate the buffer when the value changes but the schema is the same', () => { const schema = d.f32; const { rerender } = renderHook( - ({ value }) => useMirroredUniform(schema, value), + ({ value }: { value: number }) => useMirroredUniform(schema, value), { initialProps: { value: 1.0 }, }, @@ -60,7 +66,13 @@ describe('useMirroredUniform', () => { it('should recreate the buffer when the schema changes', () => { const { rerender } = renderHook( - ({ schema, value }) => useMirroredUniform(schema, value), + ({ + schema, + value, + }: { + schema: d.AnyWgslData; + value: d.Infer; + }) => useMirroredUniform(schema, value), { initialProps: { schema: d.f32, value: 1.0 } as { schema: d.AnyWgslData; @@ -84,7 +96,13 @@ describe('useMirroredUniform', () => { const schema2 = d.struct({ a: d.f32 }); const { rerender } = renderHook( - ({ schema, value }) => useMirroredUniform(schema, value), + ({ + schema, + value, + }: { + schema: d.AnyWgslData; + value: d.Infer; + }) => useMirroredUniform(schema, value), { initialProps: { schema: schema1, value: { a: 1.0 } }, }, @@ -96,4 +114,144 @@ describe('useMirroredUniform', () => { expect(createUniformMock).toHaveBeenCalledTimes(1); }); + + describe('StrictMode behavior snapshots', () => { + 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, + }, + } + `); + }); + }); }); From 36dd87b4bc4d6c240275d443112d7110fc746919 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Tue, 30 Sep 2025 10:24:58 +0200 Subject: [PATCH 16/18] Optimize schema comparison --- .../typegpu-react/src/use-mirrored-uniform.ts | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/typegpu-react/src/use-mirrored-uniform.ts b/packages/typegpu-react/src/use-mirrored-uniform.ts index c408a189f..c2493be04 100644 --- a/packages/typegpu-react/src/use-mirrored-uniform.ts +++ b/packages/typegpu-react/src/use-mirrored-uniform.ts @@ -20,20 +20,9 @@ export function useMirroredUniform< return root.createUniform(schema, value); }); const prevSchemaRef = useRef(schema); + const currentSchemaRef = useRef(schema); const cleanupRef = useRef | null>(null); - useEffect(() => { - let currentBuffer = uniformBuffer; - if (!d.deepEqual(prevSchemaRef.current as d.AnyData, schema as d.AnyData)) { - currentBuffer.buffer.destroy(); - currentBuffer = root.createUniform(schema, value); - setUniformBuffer(currentBuffer); - prevSchemaRef.current = schema; - } - - currentBuffer.write(value); - }, [schema, value, root, uniformBuffer]); - useEffect(() => { if (cleanupRef.current) { clearTimeout(cleanupRef.current); @@ -46,6 +35,26 @@ export function useMirroredUniform< }; }, [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, @@ -53,7 +62,7 @@ export function useMirroredUniform< return uniformBuffer.$; }, }), - [schema, uniformBuffer], + [currentSchemaRef.current, uniformBuffer], ); return mirroredValue as MirroredValue; From d87345633b101c71ea2f776e2572ebb327cd6787 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Tue, 30 Sep 2025 11:50:43 +0200 Subject: [PATCH 17/18] Add tests for schema stability --- .../tests/use-mirrored-uniform.test.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx index c9f3915e9..a8a5a004e 100644 --- a/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx +++ b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx @@ -254,4 +254,79 @@ describe('useMirroredUniform', () => { `); }); }); + + describe('Schema 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 } }); + + // The memoized value should be stable (same reference) + // even though schema reference changed + expect(result.current).toBe(firstResult); + expect(createUniformMock).toHaveBeenCalledTimes(1); // No buffer recreation + }); + + 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 } }); + + // The memoized value should be different (new reference) + // because schema content changed + expect(result.current).not.toBe(firstResult); + expect(createUniformMock).toHaveBeenCalledTimes(2); // Buffer recreation + }); + + 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 } }); + + // The returned schema should be the stable reference (currentSchemaRef) + // not the new schema reference + expect(result.current.schema).toBe(initialSchema); + expect(result.current.schema).toBe(schema1); // Still the original reference + expect(result.current.schema).not.toBe(schema2); // Not the new reference + }); + }); }); From 08b44df2a9c4f0f87182b5c621c3e075e4118bb8 Mon Sep 17 00:00:00 2001 From: Sebastian Piaskowy Date: Tue, 30 Sep 2025 11:52:18 +0200 Subject: [PATCH 18/18] Refactor tests for clarity and structure --- .../tests/use-mirrored-uniform.test.tsx | 263 +++++++++--------- 1 file changed, 130 insertions(+), 133 deletions(-) diff --git a/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx index a8a5a004e..4ee6a762c 100644 --- a/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx +++ b/packages/typegpu-react/tests/use-mirrored-uniform.test.tsx @@ -39,83 +39,155 @@ describe('useMirroredUniform', () => { vi.useRealTimers(); }); - 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); - }); + 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 }, - }, - ); + 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); + expect(createUniformMock).toHaveBeenCalledTimes(1); - rerender({ value: 2.0 }); + rerender({ value: 2.0 }); - expect(createUniformMock).toHaveBeenCalledTimes(1); - expect(writeMock).toHaveBeenCalledWith(2.0); + expect(createUniformMock).toHaveBeenCalledTimes(1); + expect(writeMock).toHaveBeenCalledWith(2.0); + }); }); - 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 { + 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); - expect(createUniformMock).toHaveBeenCalledTimes(1); + rerender({ schema: d.vec2f, value: d.vec2f(1, 2) }); - // Rerender with a new schema - 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 }); - expect(createUniformMock).toHaveBeenCalledTimes(2); - expect(destroyMock).toHaveBeenCalledTimes(1); - expect(createUniformMock).toHaveBeenCalledWith(d.vec2f, d.vec2f(1, 2)); + 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); + }); }); - 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 } }, - }, - ); + 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 }); - expect(createUniformMock).toHaveBeenCalledTimes(1); + const { result, rerender } = renderHook( + ({ schema, value }) => useMirroredUniform(schema, value), + { + initialProps: { schema: schema1, value: { a: 1.0 } }, + }, + ); - rerender({ schema: schema2, value: { a: 2.0 } }); + const firstResult = result.current; - expect(createUniformMock).toHaveBeenCalledTimes(1); + 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('StrictMode behavior snapshots', () => { + describe('React StrictMode compatibility', () => { const TestWrapper = ({ children }: { children: React.ReactNode }) => ( <>{children} ); @@ -254,79 +326,4 @@ describe('useMirroredUniform', () => { `); }); }); - - describe('Schema 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 } }); - - // The memoized value should be stable (same reference) - // even though schema reference changed - expect(result.current).toBe(firstResult); - expect(createUniformMock).toHaveBeenCalledTimes(1); // No buffer recreation - }); - - 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 } }); - - // The memoized value should be different (new reference) - // because schema content changed - expect(result.current).not.toBe(firstResult); - expect(createUniformMock).toHaveBeenCalledTimes(2); // Buffer recreation - }); - - 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 } }); - - // The returned schema should be the stable reference (currentSchemaRef) - // not the new schema reference - expect(result.current.schema).toBe(initialSchema); - expect(result.current.schema).toBe(schema1); // Still the original reference - expect(result.current.schema).not.toBe(schema2); // Not the new reference - }); - }); });