Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Destroy application on unmount #542

Open
wants to merge 15 commits into
base: beta
Choose a base branch
from

Conversation

trezy
Copy link
Collaborator

@trezy trezy commented Sep 3, 2024

Description of change

<Application> will now be destroyed when unmounted.

Pre-Merge Checklist
  • Tests and/or benchmarks are included
  • Documentation is changed or added
  • Lint process passed (npm run lint)
  • Tests passed (npm run test)

@trezy trezy added bug Something isn't working v8 Issues related to Pixi React v8 labels Sep 3, 2024
@trezy trezy self-assigned this Sep 3, 2024
@trezy trezy changed the base branch from main to beta September 3, 2024 12:38
@trezy trezy changed the title 521 bug v8 memoized component renders after being unmounted Destroy application on unmount Sep 3, 2024
Copy link

codesandbox-ci bot commented Sep 3, 2024

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 57434d8:

Sandbox Source
pixi.js-sandbox Configuration

Copy link
Collaborator

@lunarraid lunarraid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing I'm really uncertain of here is why the entire queue is called when an init is requested for a single Application. If that is necessary, though, the rest seems fine.

src/helpers/unmountApplication.ts Outdated Show resolved Hide resolved
}
catch (error)
{
/* ... */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably at least console.error the error, unless there is an expected error here we're trying to ignore, in which case it should probably be explicitly handled?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This try/catch wraps the app.destroy(), which may be called on an app that has already been destroyed. Pixi.js doesn't emit any useful errors in this case, so we're just eating the error.

The best solution here would probably be an upstream change to Pixi.js to throw a useful error when attempting to destroy an app that's already been destroyed, then we could handle that error appropriately. Otherwise, there's not (as far as I'm aware) a reliable way of determining whether an app has been destroyed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps check if the application has a stage? That's created at construction time and is nulled out when destroyed.

src/helpers/unmountApplications.ts Outdated Show resolved Hide resolved
@@ -167,9 +173,25 @@ export const ApplicationFunction: ForwardRefRenderFunction<PixiApplication, Appl
}
}, [defaultTextStyle]);

// eslint-disable-next-line consistent-return
useEffect(() =>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there ever a situation where a canvasElement is reused, so its root is unqueued from unmount, but then the old oninit is called from the prior render?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If an <Application> is mounted, allowed to initialise, unmounted, then remounted and allowed to reinitialise, then the canvas would be reused and onInit would be called twice. However, React's strict mode shouldn't be able to cause that thanks to the unmount queue.

{
cleanup();
});
configure({ reactStrictMode: true });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@@ -136,12 +140,14 @@ export const ApplicationFunction: ForwardRefRenderFunction<PixiApplication, Appl

if (canvasElement)
{
if (!rootRef.current)
let root = roots.get(canvasElement);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: stylistic choice, I prefer to use const whenever possible to protect against accidental re-assignment, resulting in a behaivor change of the original intention.

const root = roots.get(canvasElement) ?? createRoot(canvasElement, {}, handleInit)

{
if (root.applicationState.app)
{
root.applicationState.app.destroy();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgive my ignorance - I'm not very familiar with react-reconciler, but it strikes me that I should be able to simulate this behavior as follows in a component.

const MyComponent: React.FC = () => {
  const ref = useRef()
  useEffect(() => {
    return () => ref.current?.destroy()
  }, [])
  <Application ref={ref}>...</Application>
}

I just tried this to see if it would fix the memory leak woes, but it appears to be insufficient. Is there something about the updateContainer call that's also necessary to fully release the memory allocated by <Application>?

I also tried this with all the destroy options set to true - still no luck.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue isn't calling destroy on the Application, but rather the root being created via createRoot and stored in the global roots cache isn't being removed. The updateContainer with null first removes any child components, and then delete is called on the map to hopefully clean up the now empty fiber root.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha - do we need to also clear root.application.resizeTo here? Or is it possible that if we set this to say, window, that we'd be creating a duplicate of window that persists?

root.applicationState.app.destroy();
}

roots.delete(root.internalState.canvas!);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can say from my testing that root.internalState.canvas is undefined here, meaning that roots continues to balloon in size. I'll keep looking...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe internalState.canvas = canvas; where the canvas is created on line 78 of createRoot should do it!

import { useApplication } from '../../../src/hooks/useApplication';

describe('Application', () => {
describe('onInit', () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A test to check the size of roots and ensure that it decreases in size as expected after unmountRoot would probably be worthwhile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working v8 Issues related to Pixi React v8
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants