Skip to content

Conversation

@mrxz
Copy link
Contributor

@mrxz mrxz commented Jan 17, 2025

Description:
As discussed in #5363 replacing CommonJS (require()/module.exports) with ES Modules (import/export) is the first step. This PR is a rebase of an earlier experiment I did, which replaces most usages.

A couple of notes:

  • No build-step would still require additional work, as some dependencies aren't ESM
  • ESLint requires the ecmaVersion to be 6 or higher, but this also allows all of the ES6 (2015) syntax and constructs. There doesn't seem to be a way to only allow import/export. Though it might be time to allow ES6 in the code base(?)
  • Some import statements could be refined, e.g. import * as X from 'x.js'; followed by var y = X.y can be simplified to import { y } from 'x.js';. This is done in some places, but not all. Generally the utils is being imported whole in a lot of places, where importing specific utilities would suffice. Generally preferable to only import what is needed to aid three-shaking (down the line).

I'll place some comments on this PR to highlight some significant changes.

Changes proposed:

  • Use import/export instead of require()/module.exports

},
"rules": {
/* To work towards no-build. */
"import/extensions": ["warn", "always", { "js": "always" }],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Webpack doesn't need the .js suffix, but if the goal is to work towards a no-build setup it will be required. Thought it would be a good idea to start enforcing it regardless.

{
"files": ["./src/utils/index.js"],
"parserOptions": {
"ecmaVersion": 11
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently needed due to export as usage. The utils where the hardest part to migrate as they have very inconsistent usages of default exports and re-exports. Preferably these are streamlined and simplified, along with having consumers import only the utils they use.

Comment on lines +24 to +27
import './meta-touch-controls.js';
import './obb-collider.js';
import './obj-model.js';
import './oculus-go-controls.js';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Diff looks a tad strange, as I moved meta-touch-controls.js up to maintain alphabetical order.

var utils = require('../../utils/');
import { AFRAME_INJECTED } from '../../constants/index.js';
import { registerComponent } from '../../core/component.js';
import pkg from '../../../package.json';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Worth pointing out that importing JSON files would also not work in a no-build setup. Perhaps once JSON modules are readily available in browsers.

Copy link
Contributor

Choose a reason for hiding this comment

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

The new web standard will be:

import pkg from '../../../package.json' with { type: "json" };

currently supported by node if you test it with npm run test:node.
For webpack, babel-generator gives a warning

You are using import attributes, without specifying the desired output syntax.
Please specify the "importAttributesKeyword" generator option, whose value can be one of:
 - "with"        : `import { a } from "b" with { type: "json" };`
 - "assert"      : `import { a } from "b" assert { type: "json" };`
 - "with-legacy" : `import { a } from "b" with type: "json";`

Not sure where you configure it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For me webpack (or rather babel) complains that the @babel/plugin-syntax-import-attributes is missing, and after installing and adding it to the plugins of babel-loader, it works fine.

But I don't think there's any upside to pre-emptively using this syntax. In fact, I'd rather see us avoiding importing package.json altogether. Currently the entirety of package.json gets embedded into the A-Frame bundle, despite only needing the version property.

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree with you, I wouldn't introduce the new syntax either. We should just inject the aframe version as part of the build like we do for INSPECTOR_VERSION really. Importing package.json was only to get the aframe version when importing from node AFAICT. I don't see a real use of importing aframe in node where there is no custom elements api.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It was used for logging the THREE and WebVR polyfill versions in the past as well, but the former now uses THREE.REVISION and the latter has since been removed. Injecting the version is a lot cleaner.

Comment on lines +34 to +38

/** Resets the canInitializeElements flag, used for testing */
export function reset () {
canInitializeElements = false;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Anything imported through import is not mutable. The readyState.test.js used to override this value to simulate various scenarios. As a workaround this method is introduced.

An alternative would be to create a holder and mutate its contents, e.g.:

export var canInitializeElements = {
  value: false
}

But I'd rather add this method to accommodate tests than to change all consumers because of the tests.

import { ANode } from '../a-node.js';
import { initPostMessageAPI } from './postMessage.js';

import '../../utils/ios-orientationchange-blank-bug.js';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Conditional logic is moved into this class. So while it's always "loaded", it's only applied for iOS. This is simply due to the fact that require() is synchronous, but import() isn't. Which would then either require a top-level await, or an awkwardly placed .then()


// CSS.
if (utils.device.isBrowserEnvironment) {
window.logs = debug;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This used to be part of debug.js, but would be executed only when isBrowserEnvironment which is defined in device.js, which in turn depends on debug.js. This circular dependency caused issues.

Comment on lines +22 to +32
var loadPromise = Promise.allSettled([
import('index.js'),
import('core/scene/a-scene.js')
]).then(function (results) {
AFRAME = results[0].value.default;
AScene = results[1].value.AScene;

// Make sure WebGL context is not created since CI runs headless.
// Stubs below failed once in a while due to asynchronous test setup / teardown.
AScene.prototype.setupRenderer = function () {};
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Asynchronous loading of AFRAME and a-scene, as otherwise the above WebXR stubbing would happen too late.

el.parentNode.removeChild(el);

utils.getUrlParameter = originalGetUrlParameter;
window.history.replaceState(null, '', originalLocationUrl);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Instead of trying to overwrite the getUrlParameter util (which is now read-only) the URL is actually rewritten using the history API.

import { entityFactory } from '../../helpers.js';

var UI_CLASSES = ['.a-orientation-modal', '.a-enter-vr-button'];
var UI_CLASSES = ['.a-orientation-modal', '.a-enter-vr'];
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The a-hidden class is applied to the wrapper around the button. The test was incorrect before.

canvas = sceneEl.canvas;
sceneEl.isMobile = true;
canvas.clientWidth = 1000;
assert.isAtLeast(canvas.clientWidth, 1000);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Again an instance where one can't set values that are read-only. From my testing the line of code wasn't actually needed, but I've added an assertion to at least make sure the desired state is there.

Copy link
Contributor

Choose a reason for hiding this comment

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

On my Ubuntu machine, the window opens with width 886
AssertionError: expected 886 to be at least 1000
I changed it to 800 in #5641

Comment on lines +7 to +11
library: {
name: 'AFRAME',
type: 'var',
export: 'default'
},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This instructs WebPack to basically ensure that when loading the UMD bundle, the default export will be available under the name AFRAME. This matches the current behaviour of the bundle AFAICT.

Copy link
Contributor

@vincentfretin vincentfretin Jan 18, 2025

Choose a reason for hiding this comment

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

Yes but we should still set window.AFRAME = AFRAME; ourself for the module build used with importmap, see my patch in the last comment.

@mrxz mrxz mentioned this pull request Jan 17, 2025
Copy link
Contributor

@vincentfretin vincentfretin left a comment

Choose a reason for hiding this comment

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

Wow that's an insane amount of work you did here, thank you!


registerComponent('oculus-touch-controls', componentConfig);
module.exports.Component = registerComponent('meta-touch-controls', componentConfig);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

@dmarcos kept the "oculus-touch-controls" name for backward compatibility, depending of when we merge this before or after 1.7.0, you may need to put it back.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Overlooked this while resolving merge conflicts during the rebase as my local branch was from before the rename. Will restore it so that the component is registered under both names.

Though, thinking about it, I'm not convinced this is a good way to achieve backwards compatibility. Something trivial like the following will already start behaving differently:

<a-entity id="leftHand" laser-controls="hand: left" oculus-touch-controls="buttonHighlightColor: red" raycaster="objects: [mixin='box']"></a-entity>

The laser-controls will add the new meta-touch-controls while the oculus-touch-controls attribute will cause a second instance to be added. Pulling the trigger will result in two events being emitted...

Copy link
Contributor

Choose a reason for hiding this comment

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

True. I think we should just remove it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@dmarcos What is your thoughts on this? IMHO we should either remove it, or implement proper backwards compatibility.

Copy link
Member

Choose a reason for hiding this comment

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

I would maintain backwards compatibility. laser-controls and oculus-touch-controls are not meant to be used together. We can perhaps add a clause somewhere to make oculus-touch-controls a no-op if meta-touch-controls defined and viceversa. That way they can't be added together.

<a-entity id="leftHand" laser-controls="hand: left" oculus-touch-controls="buttonHighlightColor: red"></a-entity>

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If they aren't meant to be used together, we shouldn't use them together in the docs here 😅

Either way, a quick search on GitHub reveals that they are being used together quite a lot. Often times it's just superfluous, but even those usages will "break" when upgrading to 1.7.0. Not to mention users directly referencing el.components['oculus-touch-controls'] or querying on [oculus-touch-controls].

Since this isn't really in the scope of this PR, I'll restore the double registration for now.

if (window.debug) {
var registrationOrderWarnings = module.exports.registrationOrderWarnings = {};
}
export var registrationOrderWarnings = {};
Copy link
Contributor

Choose a reason for hiding this comment

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

Double checking this change, okay I see it's imported in tests, and it's used below in registerComponent

if (window.debug) { registrationOrderWarnings[name] = true; }

it's fine 👍

@@ -1,5 +1,5 @@
/* global assert, suite, test */
var isIOSOlderThan10 = require('utils/isIOSOlderThan10');
var isIOSOlderThan10 = require('utils/isIOSOlderThan10.js');
Copy link
Contributor

Choose a reason for hiding this comment

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

You missed this one? src/utils/isIOSOlderThan10.js is not converted either.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually it can be removed. It's only referenced in this test and never exposed in the bundle. This check seems to originally be used to workaround a bug in the webvr-polyfill on Safari.

Copy link
Contributor

Choose a reason for hiding this comment

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

Talking about webvr-polyfill we removed. I think we should also remove the cardboardModeEnabled flag in xr-mode-ui component and documentation.

warn('`utils.isOculusGo` has been deprecated, use `utils.device.isMobileVR`');
};
module.exports.isMobile = function () {
}
Copy link
Contributor

Choose a reason for hiding this comment

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

So before isGearVR and isOculusGo were available on AFRAME.utils and AFRAME.utils.device I see.
Those functions are deprecated since a long time now.
Shouldn't we just remove those two functions now @dmarcos? We also removed gearvr-controls from the repo.

Copy link
Member

Choose a reason for hiding this comment

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

yeah. we can remove those functions

Copy link
Contributor

Choose a reason for hiding this comment

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

done in #5635

// TODO: Eventually include these only if they are needed by a component.
global.THREE = THREE;
require('../../vendor/DeviceOrientationControls');
THREE.DRACOLoader = DRACOLoader;
Copy link
Contributor

Choose a reason for hiding this comment

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

Before we had

var THREE = { ...SUPER_THREE };

that was needed for node that you can test with npm run test:node
you get TypeError: Cannot add property DRACOLoader, object is not extensible
without it.

Copy link
Contributor

Choose a reason for hiding this comment

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

We also have the error with webpack output.libraryTarget: 'module' so we need it back.
Also we should use window instead of global for a future no build context. global works only via webpack or node.

var THREE = window.THREE = {...SUPER_THREE};

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now I recall having it that way before, but changing it while testing tree-shaking, as {...SUPER_THREE} prevents most tree-shaking. But indeed, it should be {...SUPER_THREE} for the time being.

Ultimately I think it would be better to have the A-Frame code import from three and its addons directly where applicable. Then construct this "SUPER_THREE + some addons" only for the final exports and populating the global scope. But that is beyond the scope of this PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes I totally agree. For example importing LightProbeGenerator should really be done in src/components/light.js. We can do that in a separate PR.

@vincentfretin
Copy link
Contributor

vincentfretin commented Jan 18, 2025

Using importmap works!

examples/boilerplate/hello-world/importmap.html

<!DOCTYPE html>                                                                    
<html>                                                                             
  <head>                                                                           
    <meta charset="utf-8">                                                         
    <title>Hello, World! • A-Frame</title>                                         
    <meta name="description" content="Hello, World! • A-Frame">                    
    <script type="importmap">                                                      
      {                                                                            
        "imports": {                                                               
          "aframe": "../../dist/aframe-master.module.min.js",                   
          "three": "https://cdn.jsdelivr.net/npm/super-three@0.172.0/build/three.module.js",
          "three/addons/": "https://cdn.jsdelivr.net/npm/super-three@0.172.0/examples/jsm/"
        }                                                                          
      }                                                                            
    </script>                                                                      
    <script type="module">                                                         
      import AFRAME from "aframe";                                                 
//      import * as THREE from "three";                                            
      import { ParametricGeometry } from "three/addons/geometries/ParametricGeometry.js";
    </script>                                                                      
  </head>                                                                          
  <body>                                                                           
    <a-scene background="color: #ECECEC">                                          
      <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" shadow></a-box>
      <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E" shadow></a-sphere>
      <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D" shadow></a-cylinder>
      <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4" shadow></a-plane>
    </a-scene>                                                                     
  </body>                                                                          
</html> 
diff --git a/src/index.js b/src/index.js
index 07b97618..6ba0c75b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -68,7 +68,7 @@ if (!window.AFRAME_ASYNC) {
   readyState.waitForDocumentReadyState();
 }
 
-export default {
+var AFRAME = {
   AComponent: Component,
   AEntity: AEntity,
   ANode: ANode,
@@ -95,3 +95,5 @@ export default {
   utils: utils,
   version: pkg.version
 };
+window.AFRAME = AFRAME;
+export default AFRAME;
diff --git a/src/lib/three.js b/src/lib/three.js
index a8e34a06..7fb4d65b 100644
--- a/src/lib/three.js
+++ b/src/lib/three.js
@@ -9,7 +9,7 @@ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUti
 import { LightProbeGenerator } from 'three/examples/jsm/lights/LightProbeGenerator.js';
 import { DeviceOrientationControls } from '../../vendor/DeviceOrientationControls.js'; // THREE.DeviceOrientationControls
 
-var THREE = global.THREE = SUPER_THREE;
+var THREE = window.THREE = {...SUPER_THREE};
 
 // TODO: Eventually include these only if they are needed by a component.
 THREE.DRACOLoader = DRACOLoader;

webpack.prod-module.config.js (remove libraryName, set libraryTarget 'module' with experiments.outputModule: true and externals three):

var path = require('path');
var webpack = require('webpack');
var TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    libraryTarget: 'module',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/dist/',
    filename: 'aframe-master.module.min.js'
  },
  experiments: {
    outputModule: true
  },
  externalsType: 'module',
  externals: {
    three: 'three'
  },
  devtool: 'source-map',
  mode: 'production',
  devServer: {
    port: process.env.PORT || 9000,
    hot: false,
    liveReload: true,
    static: {
      directory: 'examples'
    }
  },
  plugins: [
    new webpack.DefinePlugin({
      INSPECTOR_VERSION: JSON.stringify(
        process.env.INSPECTOR_VERSION
      )
    }),
    new webpack.ProvidePlugin({
      Buffer: ['buffer', 'Buffer']
    })
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader'
        }
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      }
    ]
  },
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            passes: 2
          },
          format: {
            comments: false
          }
        },
        extractComments: false
      })
    ]
  }
};

This produces a ES module dist/aframe-master.module.min.js without threejs. The first line of the bundle is

import * as __WEBPACK_EXTERNAL_MODULE_three__ from "three";
npx webpack --config webpack.prod-module.config.js
cp -rf dist/ examples/
npm start

http://localhost:8080/boilerplate/hello-world/importmap.html

@vincentfretin
Copy link
Contributor

For the npm run test:node I think we should remove that node support. I fixed it last time because I could fix it, but that is really used nowhere, nobody complained that the test was broken when we switched to three ES modules at that time, that's just useful to import aframe in node and get the version (from the doc) :D

@vincentfretin
Copy link
Contributor

For the npm run test:node I think we should remove that node support. I fixed it last time because I could fix it, but that is really used nowhere, nobody complained that the test was broken when we switched to three ES modules at that time, that's just useful to import aframe in node and get the version (from the doc) :D

I tried yesterday to fix it, that meant using the with { type: "json" } for importing package.json (and we need an additional babel plugin to support that syntax for the bundles), using type: "module" in package.json, renaming webpack.config.js to webpack.config.cjs, but in the end we are still blocked with the two conditional require for the css. In node, an ES module can't use require.

@vincentfretin
Copy link
Contributor

Just reviewed again after you force pushed, your changes are all okay. 👍 The webpack module build still working properly.
Using globalThis instead of window or global is indeed correct, I completely forgot that one existed.

@dmarcos
Copy link
Member

dmarcos commented Jan 21, 2025

Thanks for this. Regular script tag still works right?

@dmarcos
Copy link
Member

dmarcos commented Jan 21, 2025

It needs rebase as well.

@mrxz
Copy link
Contributor Author

mrxz commented Jan 21, 2025

Thanks for this. Regular script tag still works right?

Yes, that's the point of this PR. Switching from CommonJS to ESM internally while still outputting an UMD bundle that works like before.

With this done, it becomes possible to create an ESM bundle and using it in an importmap, as @vincentfretin demonstrates, but that isn't part of this PR.

@vincentfretin
Copy link
Contributor

Yes, that is good to be merged for me.
I'll create a PR that create the new ESM bundle with documentation and an example after this is merged.

@dmarcos dmarcos merged commit f43aa6d into aframevr:master Jan 22, 2025
3 checks passed
@dmarcos
Copy link
Member

dmarcos commented Jan 22, 2025

Very nice work. @vincentfretin are you working on follow up examples / docs?

@dmarcos
Copy link
Member

dmarcos commented Jan 23, 2025

@vincentfretin would be nice to have docs/examples with the new ways to import / consume aframe. Do you have anything handy we can incorporate to docs / readme / examples / maybe a glitch

@vincentfretin
Copy link
Contributor

Yes, I said in the comment just above #5632 (comment) I'll do that. ;)
I did a glitch, see my comment #5363 (comment) but I'll add it in the examples in the repo. I did today #5640 to create the module bundle. I'll create tomorrow or Saturday another PR with the example and documentation.

@dmarcos
Copy link
Member

dmarcos commented Jan 23, 2025

Yes, I said in the comment just above #5632 (comment) I'll do that. ;) I did a glitch, see my comment #5363 (comment) but I'll add it in the examples in the repo. I did today #5640 to create the module bundle. I'll create tomorrow or Saturday another PR with the example and documentation.

Thanks so much. Just making sure I understood and we’re in same page. Really excited about getting best of both modules and old school builds.

@vincentfretin
Copy link
Contributor

Example and doc in #5645

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants