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

Enable tree shaking #10

Open
simonedevit opened this issue Jan 9, 2025 · 9 comments
Open

Enable tree shaking #10

simonedevit opened this issue Jan 9, 2025 · 9 comments
Assignees
Labels
enhancement New feature or request

Comments

@simonedevit
Copy link
Owner

simonedevit commented Jan 9, 2025

As today tree shaking is not working because all packages are importing whole Babylon modules. Let's dive in:

1. Import a well known module
Import the module directly from index.js Babylon entry point (e.g import { Scene } from '@babylonjs/core')

This would be the right approach if Babylon was tree shakeable but it isn't due to side effects (https://doc.babylonjs.com/setup/frameworkPackages/es6Support#side-effects) and retro compatibility.

Solution: import a module from specific path (e.g. import { Scene } from '@babylonjs/core/scene')

Be careful: Babylon.js modules are not always entirely self-contained, and one module may depend on others so understanding the module dependencies is key.

2. Import an unknown module
Import all from Babylon module entry point (e.g. import * as BabylonCore from '@babylonjs/core')

This wouldn't be the right approach also if Babylon was tree shakeable because you are importing all modules exported from index.js by the way. The reason why it has been used this approach is that you don't know the module name (and his import path) until the execution time when the Babylon entity is created on demand by custom React reconciler.

Solution:

  • Pre-Execution Phase: during the props generation phase, build a map that associates each Babylon entity with its corresponding import path. This map will serve as a lookup table for efficient dynamic imports later during execution.

  • Execution Phase: when the application is executing and you need to dynamically import a Babylon entity, use the map generated in the props generation phase. Look up the import path associated with the requested entity and use dynamic import to load the required module at runtime.

Be careful: it can introduce complexity and potential runtime overhead. Is the deepest exported module a safe warranty to avoid to import a file containing side effects? Handle corner cases.

@simonedevit simonedevit self-assigned this Jan 9, 2025
@simonedevit simonedevit added the enhancement New feature or request label Jan 9, 2025
@simonedevit
Copy link
Owner Author

simonedevit commented Jan 11, 2025

Due to the sync nature of react-reconciler, you can't execute async operations in createInstance method. You can do it but then you should return a promise and handle it in all requiring reconciler's methods (appendChild, appendChildToContainer, insertBefore, insertInContainerBefore, etc..).

The main problem of this approach is that getPublicInstance, the exposing ref method, would return a promise that you should handle in your React component in this way:

useEffect(() => {
   ref.current.then((instance) => {
      // access here in your instance
   });
}, [ref.current]);

and i want to avoid this.

So a solution could be to dynamic import the required Babylon modules before the rendering process started by secondary Reactylon render and then serve these modules to createInstance method.

How can i dynamic import the required modules if i don't know what kind of Babylon entities will be generated? Traversing the Fiber node generated by primary React reconciler and getting the proper tag names (JSX intrinsic elements). In this way i can get the right import from the lookup table previously created and then dynamic import the specific module.

@simonedevit
Copy link
Owner Author

simonedevit commented Jan 11, 2025

The solution provided is not entirely correct because the root Fiber node does not encompass all components of the tree. For example, in the following scenario, only one between the box and the sphere will be included:

condition ? <box /> : <sphere />

@arcman7
Copy link

arcman7 commented Jan 15, 2025

The solution provided is not entirely correct because the root Fiber node does not encompass all components of the tree. For example, in the following scenario, only one between the box and the sphere will be included:

condition ? <box /> : <sphere />

Perhaps do not allow users to write syntax like that, and always render both, but have the condition dictate which is currently used by the scene? WebGPU WGSL operates in a similar manner where conditional texture look-ups are not allowed since that would alter the structure and flow of instructions sent to the GPU.

Alternatively, could you perhaps use react suspense?

It makes it possible to halt component tree rendering until specific criteria are satisfied
https://www.freecodecamp.org/news/react-suspense/

@simonedevit
Copy link
Owner Author

simonedevit commented Jan 15, 2025

If we always rendered both, they would both belong to the Fiber root, and consequently, the secondary rendering process would start for each of them. Maybe the rendering process could be skipped (e.g. by a prop) but I think the developer experience would suffer (imagining to replace the conditional rendering with a specific prop, it would be a mess finding the prop in a nested component!).

Regarding Suspense, it could be a great idea. However, I don't think it is possible with React.lazy because in createInstance it would be imported pure JavaScript objects (Babylon.js elements) and not React components. That said, if it was possible to throw a promise from the reconciler and catch it on the React side, we would leverage the power of Suspense and it would be awesome! Libraries like react-query and swr already throw a promise, but within their respective hooks that are tied to the component.

@arcman7
Copy link

arcman7 commented Jan 16, 2025

I think the development experience would suffer (imagining to replace the conditional rendering with a specific prop, it would be a mess finding the prop in a nested component!).

I guess I don't know enough to grasp how it's really any different; React dropdown components use show/hide props all the time. But also, could you maybe have the transpiler do that for you while keeping the syntax sugar of cond ? <compontentA /> : <componentB />

I thought React Suspense allows one you to async import actual code modules only when you need them thereby allowing you to cut down on the bundle size.

@simonedevit
Copy link
Owner Author

simonedevit commented Jan 19, 2025

The solution provided is not entirely correct because the root Fiber node does not encompass all components of the tree. For example, in the following scenario, only one between the box and the sphere will be included:

condition ? <box /> : <sphere />

This is true, but only for a single render! When we change the value of condition (e.g., by updating the state), a new update is triggered, and this time the Fiber node will contain the opposite JSX element. So:

Traversing the Fiber node generated by primary React reconciler and getting the proper tag names (JSX intrinsic elements).

for dynamic module imports is still a valid option.

Where does the problem lie? Updating the state from the secondary renderer won't trigger an update in the primary renderer because the renderers are independent by design. Therefore, if the update in the secondary renderer does not propagate to the primary renderer (into the Scene component belonging to the primary renderer), we cannot re-traverse the updated Fiber node, dynamically import the new module, and then call Reactylon.render(...) again.

Possible alternatives:

  1. Share the state between the two renderers via context or state management. This way, an update in the secondary nested renderer would propagate to the primary renderer.

  2. Delegate the task of manually importing and injecting the required modules to the developer in the consumer application. However, this approach would degrade the developer experience and sacrifice the power of idiomatic JSX syntax.

@arcman7
Copy link

arcman7 commented Jan 20, 2025

Possible alternatives:

  1. Share the state between the two renderers via context or state management. This way, an update in the secondary nested renderer would propagate to the primary renderer.

This would be interesting, by any means if you can provide a react context-like interface to both renderers then that would be an elegant solution.

@simonedevit
Copy link
Owner Author

simonedevit commented Jan 20, 2025

As of now, you can share context between two renderers using the Context Bridge (https://github.com/simonedevit/reactylon/blob/main/packages/library/src/core/Scene.tsx#L142) so implementing the solution it shouldn't take long.

I’m wondering whether this approach is worth it, as it introduces the overhead of re-rendering every time the state changes. For instance, if you change the color of a material, there’s no need to trigger the entire primary renderer because you don't need to dynamic import a different module. This extra cost might also explain why frameworks like react-three-fiber has chosen to directly import components from Three.js instead of using an on-demand rendering system, apart from a small subset of dynamically injected components.

@arcman7
Copy link

arcman7 commented Jan 22, 2025

has chosen to directly import components from Three.js
This is also what I do by default when working with babylon.js.

I've been thinking about this a little and it's occurred to me that dynamically injecting new babylon.js mesh materials that each likely use their own textures would cause a stutter in the rendering frame rate, so I personally wouldn't do this for any game-like application.

That being said, here's the results of a claude chat that I used to help visualize two approaches I've been considering:

1. Module Preloading with Chunking:

const Box = React.lazy(() => import('./chunks/Box'));
const Sphere = React.lazy(() => import('./chunks/Sphere'));

function Scene({ condition }) {
  useEffect(() => {
    // Preload alternate component
    const chunk = condition ? './chunks/Sphere' : './chunks/Box';
    import(chunk);
  }, []);

  return (
    <Suspense fallback={<LoadingMesh />}>
      {condition ? <Box /> : <Sphere />}
    </Suspense>
  );
}

Key characteristics:

  • Uses React's built-in lazy loading
  • Relies on Suspense for loading states
  • Components are treated as React components
  • Webpack/bundler handles code splitting automatically
  • Each import creates a new Promise

2. State-Based Module Registry:

const moduleRegistry = new Map();

function registerModule(key, importPath) {
  if (!moduleRegistry.has(key)) {
    moduleRegistry.set(key, import(importPath));
  }
  return moduleRegistry.get(key);
}

function Scene({ condition }) {
  const [BoxModule, setBoxModule] = useState(null);
  const [SphereModule, setSphereModule] = useState(null);

  useEffect(() => {
    // Register and cache both modules
    registerModule('box', '@babylonjs/core/Meshes/box')
      .then(module => setBoxModule(module));
    registerModule('sphere', '@babylonjs/core/Meshes/sphere')
      .then(module => setSphereModule(module));
  }, []);

  return condition 
    ? BoxModule && <BoxModule.Box />
    : SphereModule && <SphereModule.Sphere />;
}

Key characteristics:

  • Manual caching of module Promises
  • Direct control over module loading
  • Imports raw modules rather than React components
  • Reuses the same Promise for repeated imports
  • More explicit memory management

Main Differences:

  1. Caching Behavior:

    // Chunking approach - each import is new
    React.lazy(() => import('./chunks/Box')); // New Promise each time
    
    // Registry approach - reuses Promise
    registerModule('box', path); // Same Promise reused
  2. Loading Control:

    // Chunking approach - React handles loading
    <Suspense fallback={<LoadingMesh />}>
      <LazyComponent />
    </Suspense>
    
    // Registry approach - Manual loading control
    {moduleInstance && <ModuleComponent />}
  3. Memory Management:

    // Chunking approach - handled by React
    // Memory management is automatic but less controllable
    
    // Registry approach - explicit cleanup possible
    function cleanup() {
      moduleRegistry.clear();
    }
  4. Use Case Suitability:

    • Chunking: Better for React components and simpler implementations
    • Registry: Better for raw modules and more complex loading patterns

Example of Combined Benefits:

const moduleRegistry = new Map();

const LazyComponent = React.lazy(() => {
  const modulePromise = registerModule('component', './path');
  return modulePromise.then(module => ({
    default: props => <module.Component {...props} />
  }));
});

function Scene() {
  // Get benefits of both approaches
  return (
    <Suspense fallback={<Loading />}>
      <LazyComponent />
    </Suspense>
  );
}

To my knowledge there's no real reason we can't use react suspense with pure js modules - is this assumption wrong?

EDIT: I just realized you more or less have already refuted the above approaches in your 2nd comment of this issue:

The main problem of this approach is that getPublicInstance, the exposing ref method, would return a promise that you should handle in your React component in this way:

useEffect(() => {
   ref.current.then((instance) => {
      // access here in your instance
   });
}, [ref.current]);

and i want to avoid this.

Unless you meant specifically not wanting developers to use the react refs api.

===============
Separate Concern

In addition, you would have to manually go throw the different babylon.js modules and identify the ones that indirectly rely on others to trigger certain side effects; there's certain animation and mesh classes that you can't import in isolation without breaking them. For example see these three lines I have in one of my recent projects:

import { AnimationGroup } from "@babylonjs/core/Animations/animationGroup";
/* We need this for scene.beginDirectAnimation (side effect) */
import "@babylonjs/core/Animations/animatable.js";

Have you already encountered this issue and dealt with it?

Overall, tree shaking is starting to look like a pretty difficult feature to include without introducing a little bit of developer pain.

===============
Clarification

Earlier I mentioned using the transpiler to identify conditional rendering, and just always preload both modules, and I wanted to clarify that I meant that for that to happen at a lower level in the react framework that would cause react's renderer to still see both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

When branches are created from issues, their pull requests are automatically linked.

2 participants