Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions packages/lib/src/components/Script.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React, { ReactNode, useEffect, useRef } from 'react';
import { render } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Script } from './Script';
import { Entity } from '../Entity';
import { Script as PcScript } from 'playcanvas';
import { Application } from '../Application';
import { SubclassOf } from '../utils/types-utils';

const renderWithProviders = (ui: ReactNode) => {
return render(
<Application>
<Entity>
{ui}
</Entity>
</Application>
);
};

describe('Script Component', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('NODE_ENV', 'development');
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('should pass props to the script instance', () => {
const speed = 2;
const str = 'test';
const direction = [1, 0, 0];

class TestingScript extends PcScript {
speed: number;
direction: number[];
str: string;

initialize() {
expect(this.speed).toBe(speed);
expect(this.direction).toEqual(direction);
expect(this.str).toBe(str);
}
}

renderWithProviders(<Script script={TestingScript} speed={speed} direction={direction} str={str} />);
});

it('should forward ref to script instance', () => {

class TestScript extends PcScript {}

const TestComponent = () => {
const scriptRef = useRef<SubclassOf<PcScript>>(null);

useEffect(() => {
// Ensure the script instance is created
expect(scriptRef.current).toBeInstanceOf(TestScript);
}, []);

return <Script script={TestScript} ref={scriptRef} />;
};

renderWithProviders(<TestComponent />);
});

describe('Initialization', () => {
it('should initialize on first render', () => {
const initializeCount = vi.fn();

class TestScript extends PcScript {
initialize() {
initializeCount();
}
}

renderWithProviders(<Script script={TestScript} speed={1} />);

expect(initializeCount).toHaveBeenCalledTimes(1);
});
});

describe('Re-rendering', () => {
it('should not re-initialize when props are shallow equal', () => {
const initializeCount = vi.fn();

class TestScript extends PcScript {
initialize() {
initializeCount();
}
}

const Container = ({ children }: { children: ReactNode }) => (
<Application>
<Entity>
{children}
</Entity>
</Application>
);

const { rerender } = render(
<Container>
<Script script={TestScript} speed={1} />
</Container>
);

// Re-render with same props
rerender(
<Container>
<Script script={TestScript} speed={1} />
</Container>
);

expect(initializeCount).toHaveBeenCalledTimes(1);
});

it('should not re-initialize when props change', () => {
const initializeCount = vi.fn();

class TestScript extends PcScript {
initialize() {
initializeCount();
}
}

const Container = ({ children }: { children: ReactNode }) => (
<Application>
<Entity>
{children}
</Entity>
</Application>
);

const { rerender } = render(
<Container>
<Script script={TestScript} speed={1} />
</Container>
);

// Re-render with different props
rerender(
<Container>
<Script script={TestScript} speed={2} />
</Container>
);

expect(initializeCount).toHaveBeenCalledTimes(1);
});
});

describe('Cleanup', () => {
it('should clean up script instance on unmount', () => {
const destroySpy = vi.fn();

class UnmountScript extends PcScript {
initialize() {
this.on('destroy', destroySpy);
}
}

const { unmount } = renderWithProviders(
<Script script={UnmountScript} />
);

unmount();

expect(destroySpy).toHaveBeenCalledTimes(1);
});
});
});
26 changes: 15 additions & 11 deletions packages/lib/src/components/Script.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AppBase, Entity, Script as PcScript } from "playcanvas";
import { Script as PcScript } from "playcanvas";
import { useScript } from "../hooks"
import { FC, memo, useMemo } from "react";
import { forwardRef, memo, useMemo } from "react";
import { ComponentDefinition, validatePropsPartial } from "../utils/validation";
import { shallowEquals } from "../utils/compare";
import { SubclassOf } from "../utils/types-utils";

/**
* The Script component allows you to hook into the entity's lifecycle. This allows you to
Expand All @@ -20,26 +21,29 @@ import { shallowEquals } from "../utils/compare";
* }
* <Script script={Rotator} />
*/
const ScriptComponent: FC<ScriptProps> = (props) => {

const validatedProps = validatePropsPartial(props, componentDefinition, false);

const ScriptComponent = forwardRef<PcScript, ScriptProps>(function ScriptComponent(
props,
ref
): React.ReactElement | null {
const validatedProps = validatePropsPartial(props as ScriptProps, componentDefinition, false);

const { script, ...restProps } = validatedProps;

// Memoize props so that the same object reference is passed if props haven't changed

const memoizedProps = useMemo(() => restProps, [restProps]);

useScript(script as new (args: { app: AppBase; entity: Entity; }) => PcScript, memoizedProps);

useScript(script as SubclassOf<PcScript>, memoizedProps, ref);

return null;
};
});

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

interface ScriptProps {
script: new (args: { app: AppBase; entity: Entity; }) => PcScript;
script: SubclassOf<PcScript>;
[key: string]: unknown;
}

Expand Down
29 changes: 25 additions & 4 deletions packages/lib/src/hooks/use-script.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useRef } from 'react';
import { ForwardedRef, useEffect, useRef } from 'react';
import { useParent } from './use-parent';
import { useApp } from './use-app';
import { AppBase, Application, Entity, Script, ScriptComponent } from 'playcanvas';
import { Application, Entity, Script, ScriptComponent } from 'playcanvas';
import { SubclassOf } from '../utils/types-utils';

/**
* This hook is used to create a script component on an entity.
Expand All @@ -13,7 +14,11 @@
* myProperty: 'value',
* });
*/
export const useScript = (scriptConstructor: new (args: { app: AppBase; entity: Entity; }) => Script, props: Props) : void => {
export const useScript = (
scriptConstructor: SubclassOf<Script>,
props: Props,
ref: ForwardedRef<Script>
) : void => {

const parent: Entity = useParent();
const app: Application = useApp();
Expand All @@ -37,7 +42,15 @@
const scriptInstance = scriptComponent.create(scriptConstructor as unknown as typeof Script, {
properties: { ...props },
preloading: false,
});
}) as Script;

if (ref) {
if (typeof ref === 'function') {
ref(scriptInstance);
} else {
ref.current = scriptInstance;

Check warning on line 51 in packages/lib/src/hooks/use-script.tsx

View workflow job for this annotation

GitHub Actions / lint

Mutating component props or hook arguments is not allowed. Consider using a local variable instead
}
}

scriptRef.current = scriptInstance;
scriptComponentRef.current = scriptComponent;
Expand All @@ -52,6 +65,14 @@

if (app && app.root && script && scriptComponent) {
scriptComponent.destroy(scriptName);

if (ref) {
if (typeof ref === 'function') {
ref(null);
} else {
ref.current = null;
}
}
}
};
}, [app, parent, scriptConstructor]);
Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/utils/types-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type PublicProps<T> = {
]: T[K];
};

export type SubclassOf<T> = new () => T;

export type Serializable<T> = {
[K in keyof T]: T[K] extends Color ? string :
Expand Down
Loading