Skip to content

[RFC] Package/File Organization for Easier Bundling and Leveraging Future APIs #679

@Westbrook

Description

@Westbrook

Currently, all of our element packages are structured to leverage index.js (after running tsc) as a side effect full export of all elements included in a package. This includes both the class definitions for the various customs elements that are defined and the side effect of defining them against window.customElements the global (and currently only) element registry. Much of the reasoning for this comes from Justin Fagnani who explicates the belief that this should be so, here: https://justinfagnani.com/2019/11/01/how-to-publish-web-components-to-npm/#always-self-define-elements. This approach is further established by patterns like LitElements @customElement decorator that inherently requires a colocated class definition and define() call. However, as of late, this concept starts to run afoul of some concepts around usability in the future (and possibly at current), performance tuning of custom elements, particularly in cases when we ship a number of elements and/or sub-elements in the same package (e.g. @spectrum-web-components/button), and private API surfacing.

Future Use Cases: Scoped Custom Element Registries

As discussed in WICG/webcomponents#716 the reality of Scoped Custom Element registries may only be in development as yet. However, having been featured in the recent Web Components Face to Face held by the W3C it is highlight likely that this or a close cousin version of this API gets shipped to browsers. While presenting the current state of the API specification at this meeting Fagnani himself admitting to likely needing to revise his best practices suggestion around element registering as part of the specification getting closer to landing. It is certainly possible to leverage a trivial subclass in order to register a custom element definition in both the global and local registries, as is done in the explain like so:

// x-foo.js is an existing custom element module that registers a class
// as 'x-foo' in the global registry.
import {XFoo} from './x-foo.js';

// Create a new registry that inherits from the global registry
const myRegistry = new CustomElementRegistry(window.customElements);

// Define a trivial subclass of XFoo so that we can register it ourselves
class MyFoo extends XFoo {}

// Register it as `my-foo` locally.
myRegistry.define('my-foo', MyFoo);

However, a component package that does this by default would then be leaking details into the global scope as part of the initial registration, which means, in the case of the use case this API prepares us for: multiple registrations of elements with the same name, we could be exposing the possibility of a failed registration or in the case of our registration gating, like so:

if (!customElements.get('sp-action-menu')) {
    customElements.define('sp-action-menu', ActionMenu);
}

The first wins style of registration on which this relies can end up register an incorrect version of the element with little or no notification to the implementing engineer.

Current Use Case: Scoped Element

Teams across the web components community that are already feeling the stresses of multiple registrations and are pushing for standardization around a solution are also pushing user-space solutions to the problem in the interim. Particularly, ING (makers of Lion Elements) in partnership with OpenWC has developed a solution called Scoped Elements that extends lit-html and LitElement to support multiple registrations from a single definition. As we get closer to a fully agreed upon Scoped Registries specification, it is likely that solutions like this will continue to grow in usage and will raise the need to address the issues outlined above sooner.

Tuning for Performance

The difficulties of registering a single element in its entry points have been outlined above, and for a number of our packages, this is multiplied by multiple components being registered in an entry point at a time. Not only does this raise the runtime costs on including a package, but in some cases, it means that a user will need to register multiple components that they won't actually be using in their project in order to get one that they are. This can be keenly felt in @spectrum-web-components/button. Currently, this package delivers three different buttons by default (<sp-button>, <sp-action-button>, and <sp-clear-button>), while the @spectrum-css/button package that it is based on actually describes three more buttons (theoretically, <sp-field-button>, <sp-tool-button>, and <sp-logic-button>) that should at some point be added to the package. Users of <sp-clear-button> the lightest (in KB) are currently required to include the rest due to our current file structure which affects the performance of the applications they are developing. In some cases, they desire to prevent this reality in the context of our current directory structure causes confusion across the project where you see packages like @spectrum-web-components/menu, @spectrum-web-components/menu-group, @spectrum-web-components/menu-item pollute our scope in an effort to avoid forcing an application to define multiple elements by default like you see in @spectrum-web-components/sidenav which breaks down internally to a similarly related family of elements.

Private API Exposure

In packages like @spectrum-web-components/theme and @spectrum-web-components/shared we are actively exposing private API (which might make is the public API) by documenting the use of files inside of the lib folder of our packages. There can be some benefit to doing so across other elements, e.g. doing so will allow users of certain eslint configurations to avoid multiple import messages when they require both the side effects as well as direct access to a class in the same package, a la:

import '@spectrum-web-components/button';
import { ActionButton} from '@spectrum-web-components/button/lib/action-button';

vs

import '@spectrum-web-components/button';
import { ActionButton} from '@spectrum-web-components/button';

However, doing so makes the contents of our lib folders public to our users preventing us from easily making alterations to the contents thereof. While we've not yet encountered a need to do such a thing, the growth of our user bases through more public partnerships with Photoshop and XD will increase the likelihood of this occurring.

Recommendation

To prepare for and/or address the above situations, I'd like to propose the following basic component package directory structure (posts build, so the assets as found after installing the package as a dependency):

- src
  - component-name.css.d.ts // types
  - component-name.css.js // styles for element
  - component-name.css.js.map // source map
  - ComponentName.d.ts // types
  - ComponentName.js // class definition
  - ComponentName.js.map // source map
  - index.d.ts // types
  - index.js // re-exporting of the classes in the package (leverages the `main` and `module` field to prevent from surfacing its location)
  - index.js.map // source map
  - spectrum-component-name.css.d.ts // types
  - spectrum-component-name.css.js // Spectrum styles for element
  - spectrum-component-name.css.js.map // source map
- sp-component-name.d.ts // types
- sp-component-name.js // the side effect full registration of the custom element
- sp-component-name.js.map // source map

In a more expansive (and less theoretical) package like @specrum-web-components/button this would play out in the form of the following directory structure:

- src
  - action-button.css.d.ts // types
  - action-button.css.js // styles for element
  - action-button.css.js.map // source map
  - ActionButton.d.ts // types
  - ActionButton.js // class definition
  - ActionButton.js.map // source map
  - button-base.css.d.ts // types
  - button-base.css.js // styles for element
  - button-base.css.js.map // source map
  - button.css.d.ts // types
  - button.css.js // styles for element
  - button.css.js.map // source map
  - Button.d.ts // types
  - Button.js // class definition
  - Button.js.map // source map
  - ButtonBase.d.ts // types
  - ButtonBase.js // class definition
  - ButtonBase.js.map // source map
  - clear-button.css.d.ts // types
  - clear-button.css.js // styles for element
  - clear-button.css.js.map // source map
  - ClearButton.d.ts // types
  - ClearButton.js // class definition
  - ClearButton.js.map // source map
  - field-button.css.d.ts // types
  - field-button.css.js // styles for element
  - field-button.css.js.map // source map
  - index.d.ts // types
  - index.js // re-exporting of the classes in the package (leverages the `main` and `module` field to prevent from surfacing its location)
  - index.js.map // source map
  - spectrum-action-button.css.d.ts // types
  - spectrum-action-button.css.js // Spectrum styles for element
  - spectrum-action-button.css.js.map // source map
  - spectrum-button-base.css.d.ts // types
  - spectrum-button-base.css.js // Spectrum styles for element
  - spectrum-button-base.css.js.map // source map
  - spectrum-button.css.d.ts // types
  - spectrum-button.css.js // Spectrum styles for element
  - spectrum-button.css.js.map // source map
  - spectrum-clear-button.css.d.ts // types
  - spectrum-clear-button.css.js // Spectrum styles for element
  - spectrum-clear-button.css.js.map // source map
  - spectrum-field-button.css.d.ts // types
  - spectrum-field-button.css.js // Spectrum styles for element
  - spectrum-field-button.css.js.map // source map
- sp-action-button.d.ts // types
- sp-action-button.js // the side effect full registration of the custom element
- sp-action-button.js.map // source map
- sp-button.d.ts // types
- sp-button.js // the side effect full registration of the custom element
- sp-button.js.map // source map
- sp-clear-button.d.ts // types
- sp-clear-button.js // the side effect full registration of the custom element
- sp-clear-button.js.map // source map

This would update the previous example of needing both the side effects and the class listed above to the following:

import { ActionButton} from '@spectrum-web-components/button';
import '@spectrum-web-components/button/sp-action-button.js';

I'm presently cleaning up a branch with this change, and the associated change to the element generator yarn new-package so that this can be more closely review from a technical level and will point it to this issue. As a test case, I've already seen load management benefits from this sort of structure in the westbrook/11ty branch, where the requirements of the application have allowed for the button package to be spread out across multiple bundles thanks to this change.

Assuming this makes sense as we continue down the path towards the maturity of a 1.0, I'd also recommend that we leverage this approach in order to merge the following packages together in a form more congruous with the Spectrum CSS source packages:

  • tab + tab-list => tabs
  • menu + menu-group + menu-item => menu
  • radio + radio-group => radio

Aligning with Spectrum CSS should simplify maintenance long term as we lean more heavily of its source, Spectrum DNA, to support styling/theming across more targets, and reduce confusion when onboarding new users/developers. Merging these things will also help to clarify our documentation/demonstration content that sometimes struggles to find the right way to outline parts of larger things separate from the larger things they need to be a part of to use, see: https://opensource.adobe.com/spectrum-web-components/components/tab and https://opensource.adobe.com/spectrum-web-components/components/tab-list and their nearly identical content.

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions