ionic-team / stencil

A toolchain for building scalable, enterprise-ready component systems on top of TypeScript and Web Component standards. Stencil components can be distributed natively to React, Angular, Vue, and traditional web developers from a single, framework-agnostic codebase.
https://stenciljs.com
Other
12.5k stars 783 forks source link

feat: component versioning #3138

Open danyball opened 2 years ago

danyball commented 2 years ago

Prerequisites

Describe the Feature Request

It should be possible to set a suffix to the component names that are registered via customElements.define to be better hardened when 2 custom elements are "living" on the same page in different versions with the same name.

Describe the Use Case

We have our main landing page that is organized with a content management system. And we have several apps, written with angular, react, vue - all using our stencil lib. We have 2 Apps that are loaded at this main page. These 2 Apps are using different versions of our stencil lib that can cause issues on that page. Depending on which app is loaded first on that page and registered "their" customElements first. If app1 uses lib-v-1 with the old, blue button and app2 uses lib-v-2 with the new red one and app1 is loaded first, all the buttons of app2 are blue.

Describe Preferred Solution

Example for mylib version 1.1.0.

At build time: Add a customElements.define with <mylib-button> and <mylib-button-1-1-0>.

(optional feature) At build time of a consumer of that mylib: Add a 3. define: <mylib-button-consumername>

Describe Alternatives

Waiting for html standard to implement something like customElements.define('mylib-button', MyButton, { version: 1.1.0 }).

Related Code

No response

Additional Information

I found a solution of how it could work of a lit-element based lib: https://github.com/axa-ch-webhub-cloud/pattern-library/blob/develop/COMPONENT_VERSIONING.md

danyball commented 2 years ago

Another approach could be https://open-wc.org/docs/development/scoped-elements/

JSMike commented 2 years ago

Hi All, I just spoke with some on the Stencil team about versioning and was pointed to this issue. My team has a similar scenario where we need to support design system components that could potentially be used in multiple applications that are present on the same page, and there's potential that each application could be using a different version of the design system's components.

Especially for the tooling that wraps Stencil components for other frameworks (Angular/React/Vue) It would be great if we were able to keep the framework selectors the same so that each version of the library wouldn't result in a breaking change for consuming applications. IMO it would be best if Stencil could keep track of a generated hash (or a value from config) during build time and append that hash to the web component selector, but keep the original selector for the wrapped framework libraries. Under this example the selector would be defined as <mylib-button>, the Angular/React/Vue components would behave as if <mylib-button> was unchanged as the selector, but would be configured to point to use <mylib-button-ba583>.

I do believe that the scoped elements option is the proper long-term solution, but until the spec is finalized and implemented in enough browsers we'll need a pattern to manage versioning. Since the listed frameworks already hook/scope onto an element this would provide a path forward for those using wrapped stencil components without having to introduce breaking changes to those framework specific libraries with every update.

splitinfinities commented 2 years ago

Question, how would https://github.com/ionic-team/stencil/issues/3155 relate to this? I feel like, based on the behavior of the compiler's results, either this issue or that one may need to happen first. Or are they effectively accomplishing the same objectives, for the versioning feature? Super interested in forming a strategy around this versioning and bundling behavior so I want to dig deeper. Any advice is super helpful!

danyball commented 2 years ago

Question, how would #3155 relate to this?

3155 describes a solution for the "collections" folder and a use case where 2 stencil projects working together. I dont have experience with that case but it sounds that this issue here is solving 3155. If the version of a component is being reflect to its tagname at build time, another stencil project could consume that tag. But an app could also use tag version-tag.

danyball commented 2 years ago

My optional feature request could be covered by "tagNameTransform" but that comes with some points which should be noticed when writing the stencil lib (if a component uses another):

Its possible to solve that but maybe we could have a look at that if we/you design a solution for my optional request. This guy describes how to solve this: https://dev.to/sanderand/running-multiple-versions-of-a-stencil-design-system-without-conflicts-2f46

danyball commented 2 years ago

Referring to my last post and the optionally requested feature: There is another open issue now: https://github.com/ionic-team/stencil/issues/3269

arvindanta commented 2 years ago

Hi All, I just spoke with some on the Stencil team about versioning and was pointed to this issue. My team has a similar scenario where we need to support design system components that could potentially be used in multiple applications that are present on the same page, and there's potential that each application could be using a different version of the design system's components.

Especially for the tooling that wraps Stencil components for other frameworks (Angular/React/Vue) It would be great if we were able to keep the framework selectors the same so that each version of the library wouldn't result in a breaking change for consuming applications. IMO it would be best if Stencil could keep track of a generated hash (or a value from config) during build time and append that hash to the web component selector, but keep the original selector for the wrapped framework libraries. Under this example the selector would be defined as <mylib-button>, the Angular/React/Vue components would behave as if <mylib-button> was unchanged as the selector, but would be configured to point to use <mylib-button-ba583>.

I do believe that the scoped elements option is the proper long-term solution, but until the spec is finalized and implemented in enough browsers we'll need a pattern to manage versioning. Since the listed frameworks already hook/scope onto an element this would provide a path forward for those using wrapped stencil components without having to introduce breaking changes to those framework specific libraries with every update.

@JSMike were you able to find any work around for this usecase ?.

JSMike commented 2 years ago

@arvindanta No, I haven't had time to focused on this issue.

I believe the scoped elements that @danyball linked is a good path forward for pure web-component development, by nesting/bundling web-components in a way to ensure the expected dependency version is used, but it doesn't help for wrapping web-components for use in other frameworks (That would still require using global scope on the page).

When component scoping becomes natively available in browsers it would be great if stencil wrapped components were able to be scoped to within the context of the application they're being used with.

arvindanta commented 2 years ago

Hi, I am trying to find a way to allow multiple versions of stencil components to be used in the same page.

I am thinking of a workaround where we can use a bundler like webpack, write a loader plugin to literally string replace all references to all stencil component tag names to some different tag name of your choosing during the bundle step. fw-button to fw-button-v1 at build time.

so as Devs, you will always use fw-button in the code but at build time it gets changed to fw-button-v1.

I was able to make this work with Tree shaking. (dist/components) folder . But with lazy loading am getting the below error,

main.1464e78e.js:10335 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'isProxied')

Using Stencil 2.9.0

jared-christensen commented 1 year ago

Has anyone tried this https://dev.to/sanderand/running-multiple-versions-of-a-stencil-design-system-without-conflicts-2f46 I'm curious how well it works.

FabioGimmillaro commented 1 year ago

Hi Jared

we tried. But refactoring the whole code base was not a viable solution for us. Besides that we also had some css dependencies (mostly with the ::slotted selector) which we couldn't refactor without overriding the style in js/ts. If we considered that at the beginning of development we might have given it a chance though.

fcano-ut commented 7 months ago

I believe Scoped Custom Elements seems like the best solution, more so considering it's proposed as a web standard.

Right now it this polyfill is adding support: https://www.npmjs.com/package/@webcomponents/scoped-custom-element-registry?activeTab=versions, but it's unclear if it could be used with Stencil. Has anyone tried this?

mayerraphael commented 6 months ago

I believe Scoped Custom Elements seems like the best solution, more so considering it's proposed as a web standard.

Right now it this polyfill is adding support: https://www.npmjs.com/package/@webcomponents/scoped-custom-element-registry?activeTab=versions, but it's unclear if it could be used with Stencil. Has anyone tried this?

Got it working by passing the custom registry as part of the the defineCustomElements method options.

// Add polyfill before.

const registryA = new CustomElementRegistry();

class HostElementA extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({
        mode: 'open',
        customElements: registryA,
      }).innerHTML = `
         <style>:host { background: red; }</style>
            Wrapper<br/>
        <stencil-button>Button inside scoped registry.</stencil-button>
      `;

     // Pass through registry as options.
      defineCustomElements(window, { registry: registryA }).catch(console.warn);
    }
}

customElements.define('el-a', HostElementA);

The registry is just passed through to the define call via the defineCustomElements options.

// Patch. Check if a custom registry is supplied.
const registry = options.registry ?? window.customElements;
if (!exclude.includes(tagName) && !registry.get(tagName)) {
    cmpTags.push(tagName);
    registry.define(tagName, proxyComponent(HostElement, cmpMeta, 1 /* PROXY_FLAGS.isElementConstructor */));
}

In html you can then test it. The button inside el-a works, the button outside does not.

<el-a>dsa</el-a>
<br/>
 This button should not work:
<stencil-button>Test</patternlib-button>

image

@rwaskiewicz Allowing a custom elements registry in the defineCustomElements options object could work?

AndreBarr commented 6 months ago

I believe Scoped Custom Elements seems like the best solution, more so considering it's proposed as a web standard. Right now it this polyfill is adding support: https://www.npmjs.com/package/@webcomponents/scoped-custom-element-registry?activeTab=versions, but it's unclear if it could be used with Stencil. Has anyone tried this?

Got it working by passing the custom registry as part of the the defineCustomElements method options.

// Add polyfill before.

const registryA = new CustomElementRegistry();

class HostElementA extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({
        mode: 'open',
        customElements: registryA,
      }).innerHTML = `
         <style>:host { background: red; }</style>
            Wrapper<br/>
        <stencil-button>Button inside scoped registry.</stencil-button>
      `;

     // Pass through registry as options.
      defineCustomElements(window, { registry: registryA }).catch(console.warn);
    }
}

customElements.define('el-a', HostElementA);

The registry is just passed through to the define call via the defineCustomElements options.

// Patch. Check if a custom registry is supplied.
const registry = options.registry ?? window.customElements;
if (!exclude.includes(tagName) && !registry.get(tagName)) {
    cmpTags.push(tagName);
    registry.define(tagName, proxyComponent(HostElement, cmpMeta, 1 /* PROXY_FLAGS.isElementConstructor */));
}

In html you can then test it. The button inside el-a works, the button outside does not.

<el-a>dsa</el-a>
<br/>
 This button should not work:
<stencil-button>Test</patternlib-button>

image

@rwaskiewicz Allowing a custom elements registry in the defineCustomElements options object could work?

@mayerraphael How were you able to add the custom registry as an option to the defineCustomElements method? Did you make changes in a local version of stencil?

fcano-ut commented 6 months ago

For anyone looking for a workaround, we managed to make it working doing something like this:

const backup = window.customElements;

window.customElements = registry; // pass your registry here
defineCustomElements(...) // call defineCustomElements
window.customElements = backup; // restore the customElements global to what it was

That works without any change in Stencyl, but it's not pretty. I'm hoping they can add library support for this soon as @mayerraphael suggested, to stop using this kind of "hacky" workaround

mayerraphael commented 6 months ago

This only works in older versions of Stencil. In newer versions defineCustomElements is async. If you have other registrations in between it breaks.

See: https://github.com/ionic-team/stencil/blob/41f877ec48200dee0483691b4e5e519073d392dd/src/compiler/output-targets/dist-lazy/lazy-output.ts#L189

FabioGimmillaro commented 6 months ago

I wrote a script which manipulates the generates dist/esm sources to also accept a registry as an input parameter:

const path = require("path");
const fs = require("fs");

const distPath = path.resolve(__dirname, "../dist/");
const esmPath = `${distPath}\\esm`;

const indexFileName = fs
  .readdirSync(esmPath)
  .find((fn) => fn.startsWith("index-") && fn.endsWith(".js"));

const indexFile = `${esmPath}\\${indexFileName}`
const fileContent = fs.readFileSync(indexFile, "utf-8");

console.log("Adjust indexFile", indexFile);

return fs.promises.writeFile(indexFile, fileContent.replace(/const customElements = win.customElements;/, "const customElements = options.registry ?? win.customElements;"), "utf-8")

I wrote the script for StencilJS 2.22.3 and the "dist" output target so I don't know if this also works for StencilJS versions >= 3.

I'm currently in my testing phase so I don't know if this is a viable solution for most use-cases

The call to defineCustomElements should look like this: defineCustomElements(window, { registry: $newRegistry });

mayerraphael commented 6 months ago

I wrote a script which manipulates the generates dist/esm sources to also accept a registry as an input parameter:

const path = require("path");
const fs = require("fs");

const distPath = path.resolve(__dirname, "../dist/");
const esmPath = `${distPath}\\esm`;

const indexFileName = fs
  .readdirSync(esmPath)
  .find((fn) => fn.startsWith("index-") && fn.endsWith(".js"));

const indexFile = `${esmPath}\\${indexFileName}`
const fileContent = fs.readFileSync(indexFile, "utf-8");

console.log("Adjust indexFile", indexFile);

return fs.promises.writeFile(indexFile, fileContent.replace(/const customElements = win.customElements;/, "const customElements = options.registry ?? win.customElements;"), "utf-8")

I wrote the script for StencilJS 2.22.3 and the "dist" output target so I don't know if this also works for StencilJS versions >= 3.

I'm currently in my testing phase so I don't know if this is a viable solution for most use-cases

The call to defineCustomElements should look like this: defineCustomElements(window, { registry: $newRegistry });

Again, defineCustomElements is async in newer Stencil Versions.

Imagine you have multiple microfrontends each requiring a custom registry, overwriting the global window.customElements Registry in an async initialization process will be pure chaos. I highly recommend not doing that.

The only viable option is that the internal define call in defineCustomElements has the correct registry so it works nicely with the async nature of JS.

fcano-ut commented 6 months ago

@mayerraphael I agree, the only "proper" solution is support at the Stencil level, but we need workarounds in case that takes a lot...

If defineCustomElements is a promise, something like this should work and be safe I guess? We haven't tested it, but we'll figure it out when we update Stencil

const backup = window.customElements;

new Promise(resolve => {
  window.customElements = registry; // pass your registry here
  resolve(defineCustomElements(...)); // call defineCustomElements
}).then(() => {
  window.customElements = backup; // restore the customElements global to what it was
});
mayerraphael commented 6 months ago

@fcano-ut

@mayerraphael I agree, the only "proper" solution is support at the Stencil level, but we need workarounds in case that takes a lot...

If defineCustomElements is a promise, something like this should work and be safe I guess? We haven't tested it, but we'll figure it out when we update Stencil

const backup = window.customElements;

new Promise(resolve => {
  window.customElements = registry; // pass your registry here
  resolve(defineCustomElements(...)); // call defineCustomElements
}).then(() => {
  window.customElements = backup; // restore the customElements global to what it was
});

Lets say you have a shell and a microfrontend app, both having different Versions of your component library.

// Register shell on global window.customElements.
shell.defineCustomElements();

// Register app.
backup = window.customElements;
window.customElements = myAppRegistry;
microfrontend.defineCustomElements().then(() =>window.customElements = backup);

It could happen that the shell is not done regsitering while you override the global window.customElements and also registering other versioned components in between => Chaos.

The only thing helping is synchronizing, but this blocks your frontend (one after the other => slow)

// Register shell on global window.customElements.
shell.defineCustomElements().then(() => {
  // Register app.
  backup = window.customElements;
  window.customElements = myAppRegistry;
  microfrontend.defineCustomElements().then(() =>window.customElements = backup);
});

But i dont wannt to know what other Stencil processes running async this messes with. So just don't do it.

It would be easier to patch the Stencil runtime (index.js) by providing the registry in the options than guaranteeing that overriding the global registry works in a correct way.

fcano-ut commented 6 months ago

I get your point, @mayerraphael, you're right. This totally breaks if the defineCustomElements function has async steps inside and if you register multiple micro-frontends at once, since the order of execution will not be guaranteed. Thanks for thinking this through. We'll abstain for updating stencil for now I guess 😐

FabioGimmillaro commented 6 months ago

@fcano-ut Wouldn't it be enough if StencilJS provided an API to add a new CustomElementRegistry when calling defineCustomElements? e.g.

defineCustomElements(window, { customElements: new CustomElementRegistry() });

or return a customElementsRegistry when calling defineCustomElements? Whether a new customElementsRegistry is created could be configured in the stencil.config.ts by adding a flag in the extras section

const { customElementsRegistry } = defineCustomElements(window);
mayerraphael commented 6 months ago

I believe Scoped Custom Elements seems like the best solution, more so considering it's proposed as a web standard. Right now it this polyfill is adding support: https://www.npmjs.com/package/@webcomponents/scoped-custom-element-registry?activeTab=versions, but it's unclear if it could be used with Stencil. Has anyone tried this?

Got it working by passing the custom registry as part of the the defineCustomElements method options.

// Add polyfill before.

const registryA = new CustomElementRegistry();

class HostElementA extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({
        mode: 'open',
        customElements: registryA,
      }).innerHTML = `
         <style>:host { background: red; }</style>
            Wrapper<br/>
        <stencil-button>Button inside scoped registry.</stencil-button>
      `;

     // Pass through registry as options.
      defineCustomElements(window, { registry: registryA }).catch(console.warn);
    }
}

customElements.define('el-a', HostElementA);

The registry is just passed through to the define call via the defineCustomElements options.

// Patch. Check if a custom registry is supplied.
const registry = options.registry ?? window.customElements;
if (!exclude.includes(tagName) && !registry.get(tagName)) {
    cmpTags.push(tagName);
    registry.define(tagName, proxyComponent(HostElement, cmpMeta, 1 /* PROXY_FLAGS.isElementConstructor */));
}

In html you can then test it. The button inside el-a works, the button outside does not.

<el-a>dsa</el-a>
<br/>
 This button should not work:
<stencil-button>Test</patternlib-button>

image @rwaskiewicz Allowing a custom elements registry in the defineCustomElements options object could work?

@mayerraphael How were you able to add the custom registry as an option to the defineCustomElements method? Did you make changes in a local version of stencil?

Exactly, i just patched the runtime.

AndreBarr commented 6 months ago

I wrote a script which manipulates the generates dist/esm sources to also accept a registry as an input parameter:

const path = require("path");
const fs = require("fs");

const distPath = path.resolve(__dirname, "../dist/");
const esmPath = `${distPath}\\esm`;

const indexFileName = fs
  .readdirSync(esmPath)
  .find((fn) => fn.startsWith("index-") && fn.endsWith(".js"));

const indexFile = `${esmPath}\\${indexFileName}`
const fileContent = fs.readFileSync(indexFile, "utf-8");

console.log("Adjust indexFile", indexFile);

return fs.promises.writeFile(indexFile, fileContent.replace(/const customElements = win.customElements;/, "const customElements = options.registry ?? win.customElements;"), "utf-8")

I wrote the script for StencilJS 2.22.3 and the "dist" output target so I don't know if this also works for StencilJS versions >= 3. I'm currently in my testing phase so I don't know if this is a viable solution for most use-cases The call to defineCustomElements should look like this: defineCustomElements(window, { registry: $newRegistry });

Again, defineCustomElements is async in newer Stencil Versions.

Imagine you have multiple microfrontends each requiring a custom registry, overwriting the global window.customElements Registry in an async initialization process will be pure chaos. I highly recommend not doing that.

The only viable option is that the internal define call in defineCustomElements has the correct registry so it works nicely with the async nature of JS.

@mayerraphael Isn't the script @FabioGimmillaro is running here, the same as the patch you are doing? The global window.customElements is not being overritten.