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