Skip to content

Commit 84e9df5

Browse files
authored
Add unit tests for Script component and refactor useScript hook (#129)
- Introduced comprehensive unit tests for the Script component, validating prop passing, ref forwarding, initialization, re-rendering behavior, and cleanup on unmount. - Refactored the useScript hook to support ref forwarding, enhancing the integration with the Script component. - Updated type definitions to improve type safety and clarity in the codebase. These changes enhance the testing coverage and improve the overall functionality of the Script component and its associated hooks.
1 parent 7b5ada2 commit 84e9df5

File tree

4 files changed

+212
-15
lines changed

4 files changed

+212
-15
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import React, { ReactNode, useEffect, useRef } from 'react';
2+
import { render } from '@testing-library/react';
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
import { Script } from './Script';
5+
import { Entity } from '../Entity';
6+
import { Script as PcScript } from 'playcanvas';
7+
import { Application } from '../Application';
8+
import { SubclassOf } from '../utils/types-utils';
9+
10+
const renderWithProviders = (ui: ReactNode) => {
11+
return render(
12+
<Application>
13+
<Entity>
14+
{ui}
15+
</Entity>
16+
</Application>
17+
);
18+
};
19+
20+
describe('Script Component', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
vi.stubEnv('NODE_ENV', 'development');
24+
});
25+
26+
afterEach(() => {
27+
vi.unstubAllEnvs();
28+
});
29+
30+
it('should pass props to the script instance', () => {
31+
const speed = 2;
32+
const str = 'test';
33+
const direction = [1, 0, 0];
34+
35+
class TestingScript extends PcScript {
36+
speed: number;
37+
direction: number[];
38+
str: string;
39+
40+
initialize() {
41+
expect(this.speed).toBe(speed);
42+
expect(this.direction).toEqual(direction);
43+
expect(this.str).toBe(str);
44+
}
45+
}
46+
47+
renderWithProviders(<Script script={TestingScript} speed={speed} direction={direction} str={str} />);
48+
});
49+
50+
it('should forward ref to script instance', () => {
51+
52+
class TestScript extends PcScript {}
53+
54+
const TestComponent = () => {
55+
const scriptRef = useRef<SubclassOf<PcScript>>(null);
56+
57+
useEffect(() => {
58+
// Ensure the script instance is created
59+
expect(scriptRef.current).toBeInstanceOf(TestScript);
60+
}, []);
61+
62+
return <Script script={TestScript} ref={scriptRef} />;
63+
};
64+
65+
renderWithProviders(<TestComponent />);
66+
});
67+
68+
describe('Initialization', () => {
69+
it('should initialize on first render', () => {
70+
const initializeCount = vi.fn();
71+
72+
class TestScript extends PcScript {
73+
initialize() {
74+
initializeCount();
75+
}
76+
}
77+
78+
renderWithProviders(<Script script={TestScript} speed={1} />);
79+
80+
expect(initializeCount).toHaveBeenCalledTimes(1);
81+
});
82+
});
83+
84+
describe('Re-rendering', () => {
85+
it('should not re-initialize when props are shallow equal', () => {
86+
const initializeCount = vi.fn();
87+
88+
class TestScript extends PcScript {
89+
initialize() {
90+
initializeCount();
91+
}
92+
}
93+
94+
const Container = ({ children }: { children: ReactNode }) => (
95+
<Application>
96+
<Entity>
97+
{children}
98+
</Entity>
99+
</Application>
100+
);
101+
102+
const { rerender } = render(
103+
<Container>
104+
<Script script={TestScript} speed={1} />
105+
</Container>
106+
);
107+
108+
// Re-render with same props
109+
rerender(
110+
<Container>
111+
<Script script={TestScript} speed={1} />
112+
</Container>
113+
);
114+
115+
expect(initializeCount).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it('should not re-initialize when props change', () => {
119+
const initializeCount = vi.fn();
120+
121+
class TestScript extends PcScript {
122+
initialize() {
123+
initializeCount();
124+
}
125+
}
126+
127+
const Container = ({ children }: { children: ReactNode }) => (
128+
<Application>
129+
<Entity>
130+
{children}
131+
</Entity>
132+
</Application>
133+
);
134+
135+
const { rerender } = render(
136+
<Container>
137+
<Script script={TestScript} speed={1} />
138+
</Container>
139+
);
140+
141+
// Re-render with different props
142+
rerender(
143+
<Container>
144+
<Script script={TestScript} speed={2} />
145+
</Container>
146+
);
147+
148+
expect(initializeCount).toHaveBeenCalledTimes(1);
149+
});
150+
});
151+
152+
describe('Cleanup', () => {
153+
it('should clean up script instance on unmount', () => {
154+
const destroySpy = vi.fn();
155+
156+
class UnmountScript extends PcScript {
157+
initialize() {
158+
this.on('destroy', destroySpy);
159+
}
160+
}
161+
162+
const { unmount } = renderWithProviders(
163+
<Script script={UnmountScript} />
164+
);
165+
166+
unmount();
167+
168+
expect(destroySpy).toHaveBeenCalledTimes(1);
169+
});
170+
});
171+
});

packages/lib/src/components/Script.tsx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { AppBase, Entity, Script as PcScript } from "playcanvas";
1+
import { Script as PcScript } from "playcanvas";
22
import { useScript } from "../hooks"
3-
import { FC, memo, useMemo } from "react";
3+
import { forwardRef, memo, useMemo } from "react";
44
import { ComponentDefinition, validatePropsPartial } from "../utils/validation";
55
import { shallowEquals } from "../utils/compare";
6+
import { SubclassOf } from "../utils/types-utils";
67

78
/**
89
* The Script component allows you to hook into the entity's lifecycle. This allows you to
@@ -20,26 +21,29 @@ import { shallowEquals } from "../utils/compare";
2021
* }
2122
* <Script script={Rotator} />
2223
*/
23-
const ScriptComponent: FC<ScriptProps> = (props) => {
24-
25-
const validatedProps = validatePropsPartial(props, componentDefinition, false);
2624

25+
const ScriptComponent = forwardRef<PcScript, ScriptProps>(function ScriptComponent(
26+
props,
27+
ref
28+
): React.ReactElement | null {
29+
const validatedProps = validatePropsPartial(props as ScriptProps, componentDefinition, false);
30+
2731
const { script, ...restProps } = validatedProps;
28-
29-
// Memoize props so that the same object reference is passed if props haven't changed
32+
3033
const memoizedProps = useMemo(() => restProps, [restProps]);
31-
32-
useScript(script as new (args: { app: AppBase; entity: Entity; }) => PcScript, memoizedProps);
34+
35+
useScript(script as SubclassOf<PcScript>, memoizedProps, ref);
36+
3337
return null;
34-
};
38+
});
3539

3640
// Memoize the component to prevent re-rendering if `script` or `props` are the same
3741
export const Script = memo(ScriptComponent, (prevProps, nextProps) => {
3842
return prevProps.script === nextProps.script && shallowEquals(prevProps, nextProps)
3943
});
4044

4145
interface ScriptProps {
42-
script: new (args: { app: AppBase; entity: Entity; }) => PcScript;
46+
script: SubclassOf<PcScript>;
4347
[key: string]: unknown;
4448
}
4549

packages/lib/src/hooks/use-script.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useEffect, useRef } from 'react';
1+
import { ForwardedRef, useEffect, useRef } from 'react';
22
import { useParent } from './use-parent';
33
import { useApp } from './use-app';
4-
import { AppBase, Application, Entity, Script, ScriptComponent } from 'playcanvas';
4+
import { Application, Entity, Script, ScriptComponent } from 'playcanvas';
5+
import { SubclassOf } from '../utils/types-utils';
56

67
/**
78
* This hook is used to create a script component on an entity.
@@ -13,7 +14,11 @@ import { AppBase, Application, Entity, Script, ScriptComponent } from 'playcanva
1314
* myProperty: 'value',
1415
* });
1516
*/
16-
export const useScript = (scriptConstructor: new (args: { app: AppBase; entity: Entity; }) => Script, props: Props) : void => {
17+
export const useScript = (
18+
scriptConstructor: SubclassOf<Script>,
19+
props: Props,
20+
ref: ForwardedRef<Script>
21+
) : void => {
1722

1823
const parent: Entity = useParent();
1924
const app: Application = useApp();
@@ -37,7 +42,15 @@ export const useScript = (scriptConstructor: new (args: { app: AppBase; entity:
3742
const scriptInstance = scriptComponent.create(scriptConstructor as unknown as typeof Script, {
3843
properties: { ...props },
3944
preloading: false,
40-
});
45+
}) as Script;
46+
47+
if (ref) {
48+
if (typeof ref === 'function') {
49+
ref(scriptInstance);
50+
} else {
51+
ref.current = scriptInstance;
52+
}
53+
}
4154

4255
scriptRef.current = scriptInstance;
4356
scriptComponentRef.current = scriptComponent;
@@ -52,6 +65,14 @@ export const useScript = (scriptConstructor: new (args: { app: AppBase; entity:
5265

5366
if (app && app.root && script && scriptComponent) {
5467
scriptComponent.destroy(scriptName);
68+
69+
if (ref) {
70+
if (typeof ref === 'function') {
71+
ref(null);
72+
} else {
73+
ref.current = null;
74+
}
75+
}
5576
}
5677
};
5778
}, [app, parent, scriptConstructor]);

packages/lib/src/utils/types-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type PublicProps<T> = {
3333
]: T[K];
3434
};
3535

36+
export type SubclassOf<T> = new () => T;
3637

3738
export type Serializable<T> = {
3839
[K in keyof T]: T[K] extends Color ? string :

0 commit comments

Comments
 (0)