v5 Breaking Changes Proposals / Discussion
(please note - this is a live document. I'm adding more things as I work on them)
Over the past couple of months I've been working hard getting Stencil core into a better place; squashing lots of long-standing, annoying bugs (this is continuing) plus adding long .... long requested features that Stencil should have had earlier (extends, Mixin, @Prop get / set, runtime custom @Decorators to name a few).
As I look to v5 I'm looking at consolidation, organisation and deprecation - this will include breaking changes.
Stencil has always been pretty stable when it comes to it's API. Whilst this is nice from a upgrade POV it has come at the cost of technical debt - Stencil recently had it's 10th birthday - 10 years worth of technical debt!
In this issue I want to publicly make known what is currently just in my head (or actively being worked on) and let's discuss it here.
BREAKING CHANGE - Integrated testing removal
Back in the days of 2016, setting up testing for front-end things was hard. The original authors wanted a first class, seamless testing experience which involved a hard-coupling with a custom Jest runner and environment, in combination with a custom Puppeteer setup.
This coupling has hamstrung Stencil core and it's users in many ways.
- Stencil core needs to offer a different, custom Jest setup for every major version of Jest. When this became too burdensome to maintain, users became locked to Jest v29[^1]
- Stencil's
--spectests do a 'compile lite' on-the-fly for a given component class, then bootstrap it in Stencil's integrated node-dom -MockDoc. This whole process is increasingly problematic:- it reaches into Stencil's internals to work which is prone to error and is fundamentally not representative of the final output.
- is very difficult to make work with classes that can now use
extends(optionally in combination with Mixin) - doesn't give devs the option to use their 'node dom' of choice (e.g. happy-dom, JSDOM)
- Stencil's
--e2etests are hard baked to use Puppeteer which doesn't have the same DevX around browser testing as newer options like Cypress and Playwright.
Migration pathway
- For
--specstyle tests, migrate to using https://github.com/stenciljs/vitest. It has a similar API to the current Jest integration but gives devs far greater control. Test against different bundles / outputs, test in a real browser (a11y tests, visual regressions etc), or pick your node-dom of choice. - For
--e2estyle tests I would probably say that many library authors actually want 'component' tests - isolated testing of their components that just happen to run in a browser - again - use https://github.com/stenciljs/vitest. For those cases where actual e2e tests are required (routing, full applications, proper onload initialisation, SSR) use https://github.com/stenciljs/playwright
BREAKING CHANGE - @Component API
The @Component API is convoluted and not very amenable to updates or changes.
Current
interface ComponentConfig {
tag: string;
/* not relevant unless you use `shadow` */
formAssociated?: boolean;
/* don't use shadowDOM < use scoped classes instead */
scoped?: boolean;
/* use shadowDOM if true ... use nothing if false. Or you can omit to use nothing // then you can also have scoped */
shadow?: boolean | {
delegatesFocus?: boolean, slotAssignment?: 'manual' | 'named'
}
// .. some other things around styling / assets that make sense //
}Proposed
interface ComponentConfig {
tag: string;
encapsulation?:
| { type: 'shadow'; mode?: 'open' | 'closed'; delegatesFocus?: boolean; slotAssignment?: 'manual' | 'named' }
| { type: 'none'; patches?: ('all' | 'children' | 'clone' | 'insert')[] }
| { type: 'scoped'; patches?: ('all' | 'children' | 'clone' | 'insert')[] };
// ^ nb - these patches can be applied globally via `config.extras` as they now
// other unrelated config
}Whilst this is slightly more verbose I think it's much clearer around intent plus much easier to add more options in future. Feedback welcome.
formAssociation will move into the @AttachInternals decorator options
Migration pathway
A new stencil migrate CLI option will be added to update from old > new
BREAKING CHANGE - CommonJS
Stencil itself will migrate it's compiled source from CJS to ESM.
User's dist and dist-hydrate-script output targets now are opt-in via a cjs: true config option within stencil.config.
Migration pathway
Add cjs: true to both dist and dist-hydrate-script outputs to continue getting commonjs output. Additionally, read the specific breaking changes below regarding dist-hydrate-script.
BREAKING CHANGE - ES5 / Polyfills removal
Removal of all options around old polyfills / shims and ES5, 'system' modules.
Migration pathway
Users should 1) remove any imports and calls to applyPolyfills() from their dist output and 2) remove the es5 config option their stencil.config
BREAKING CHANGE - internal structure
Many internal paths will be moved / renamed / become their own packages
@stencil/core/internal→@stencil/core/runtime@stencil/core/internal/client→@stencil/core/runtime/client@stencil/core/internal/hydrate→@stencil/core/runtime/server@stencil/core/cli→@stencil/cli@stencil/core/dev-server→@stencil/dev-server@stencil/core/mock-doc→@stencil/mock-doc
BREAKING CHANGE - openBrowser
The dev-server will no longer open the browser by default.
Migration pathway
- No need to pass in
--no-openanymore. - Pass in
--open(in the cli) oropenBrowserto thedevServerconfig options, for previous auto-open behaviour
BREAKING CHANGE - @Watch initialisation
The documentation around Stencil's lifecycle (https://stenciljs.com/docs/component-lifecycle#component-lifecycle-methods) is quite clear; watchers should, by default, not fire until the component has been fully rendered. At present - especially within the dist output, watch methods can be called during every lifecycle stage.
In v5 @Watch methods adhere to the documentation. Whilst this is strictly a fix, the current behaviour is so long-standing users may have started to rely on it.
Migration pathway
If you need to call watch methods before the component is completely ready, manually call them within lifecycle methods.
Alternatively, use the immediate flag - @Watch({ immediate: true }).
BREAKING CHANGE - Rollup replaced with Rolldown
In v5 Rollup was removed and replaced with Rolldown; all references to rollup* in stencil.config have been replaced accordingly.
Migration pathway
Replace any rollup* references with rolldown* in stencil.config (running stencil migrate will automatically do this).
If referencing any rollup types or internals, make sure to find out what the equivalents are in rolldown.
A number of supported rollup options have no new equivalents (so should be removed) whilst others have new names; running stencil migrate will automatically do handle this for you.
BREAKING CHANGES - dist-hydrate-script output
- Output name has changed from
dist-hydrate-scripttossr(read about all core output targets renamed below). - The output no-longer writes a
package.jsonfile - The default
hydrate.jsis now esm whilst commonJS is opt-in (viacjs: true) and outputs ashydrate.cjs - The exported
hydrateDocumenthas been renamed tossrDocument(hydrateDocumentis still exported asdeprecatedfor now) - All config options with
*hydrate(beforeHydrate/afterHydrate) have been renamed to*ssr(*hydrateare still exported asdeprecatedoptions for now) - To make the output environment agnostic (not just Node.js) the
streamToString()return type has been changed from Node.jsReadableto web-standardReadableStream<string>which works in Node 22+, Cloudflare Workers, Deno, Bun, and all WinterCG runtimes.
Migration pathway
Users must make sure to adjust their package.json exports accordingly - pointing to updated file names.
BREAKING CHANGES - remove buildDist and buildDocs global config options.
The buildDist and buildDocs global configuration options are
- not particularly clear in intent
- pretty nuclear (e.g. users are forced via
buildDocsto build ALL docs outputs during dev) - In the case of
buildDistis slightly inconsistent (other flags can overrule whether outputs are built or not)
A new, per output target has been added skipInDev: boolean allowing devs granular control over what is built during --dev / devMode. By default everything else is skipInDev: true except for the dist browser bundle (and related www output)
Migration pathway
buildDist and buildDocs are detected during cli tasks and users are invited to automatically migrate (using stencil migrate)
BREAKING CHANGES - core output targets renamed
dist and dist-custom-elements are not at all obvious what they are or what they do. The naming comes from original decisions made about the project; dist always designed to be the 'main' target, dist-custom-elements being a later addition when users requested standalone / cherry-pickable components.
These original decisions resulted in dist-custom-elements always seeming like an afterthought; some work being copied (like type generation) whilst other work (like globalStyles) being completely omitted. With this in-mind, the 2 targets have become equal in weighting with other implicit outputs now being explicitly configurable (if required, but not necessary).
TLDR:
dist>loader-bundle. Default output dir wasdistnowdist/loader-bundledist-custom-elements>standalone. Default output dir wascustom-elementsnow isdist/standalonedist-hydrate-script>ssr. Default output dir washydratenow isdist/ssr- new (optional)
typesoutput. Customisable withskipInDevanddiroptions - defaults todist/types. Is auto-generated in production builds. - new (optional)
collectionis now an explicit output. Customisable withskipInDevanddiroptions - defaults todist/collection. Is auto-generated in production builds. dist.typesDirremoved. Usetypes.dirdist.collectionsDirremoved. Usecollection.dirdist-custom-elements.isPrimaryPackageOutputTargetremoved. Choose your own default export inpackage.json(cli hints will guide you to choose one depending on your outputs).dist-custom-elements.generateTypeDeclarationsremoved. Auto generated and written totypes.dirdist.esmLoaderPathrenamed toloaderPath. Path is now calculated fromdist/loader-bundleinstead ofdist(so useloaderPath: '../'to recreate old behaviour)
Migration pathway
Deprecated outputs are detected during cli tasks and users are invited to automatically migrate (using stencil migrate).
Be mindful of the new default output directories - you may need to update package.json exports or explicitly set the dir in each output if you want to retain old / legacy paths.
For the dist cdn path, if you want to retain the same path as before (because the default path is now dist/loader-bundle (was dist)) set buildDir: '../'
BREAKING CHANGES - prod / dev
- The
--prodcli flag has been removed (was redundant as prod builds are the default without explicit--devflag) - The
devModeconfig option has been removed. Antithetical to usual safety norms - it should be explicit and obvious to devs when they're opting into a dev build - not hidden in a config option.
Migration pathway
Devs should remove --prod from their cli commands. Won't error - just not required.
stencil migrate will automatically remove devMode from stencil.config. Devs should use the --dev cli flag.
BREAKING CHANGE - module extensions
With cjs being a fringe requirement and (therefore becoming opt-in in Stencil), type: module is now strongly recommended and old file extension formats (*.cjs.js / *.esm.js) will be standardised to *.cjs (should you opt-in to cjs) and *.js)
Migration pathway
Devs will need to update properties in their package.json. Extra warnings are raised when key package.json fields point to non-existing paths.
note - because the browser loader-bundle output is public facing, references to it may not be controlled by library maintainers. Therefore the current main entrypoint - path/to/loader/NAMESPACE.esm.js - will not be removed and kept as a forwarding module.
BREAKING CHANGE - extras.addGlobalStyleToComponents removed
In v5 globalStyle has been elevated to it's own configurable output target (and you can have multiple now!). Although the current globalStyle config property is still available for ease of use, the extras.addGlobalStyleToComponents has been removed.
Migration pathway
A new inject property is available on any global-style output target:
{
type: 'global-style',
inject: 'client' // 'all' | 'none' < default
...
} Explicit extras.addGlobalStyleToComponents is detected during cli tasks and users are invited to automatically migrate (using stencil migrate) which will create an explicit global-style output target with corresponding inject setting.
BREAKING CHANGE - JSX types
Previously, JSX.Element was undefined in the h namespace, causing ts to silently fall back to any for JSX expressions (which surfaced as no-unsafe-return errors in strict @typescript-eslint setups, for example).
3 type-level changes:
h.JSX.Element > VNode (was FunctionalComponent<...>)
Host / Fragment > (props) => VNode(wasFunctionalComponent<...>) FunctionalComponent>VNode | null(wasVNode | VNode[] | null`)
Migration pathway
No runtime changes.
If you have a FunctionalComponent<T> that returns an array (via utils.map or similar), wrap the result in a fragment instead:
// Before
const MyList: FunctionalComponent<Props> = (props, children, utils) => utils.map(children, transform);// After
const MyList: FunctionalComponent<Props> = (props, children, utils) => <>{utils.map(children, transform)}</>;BREAKING CHANGE - extras.*
The extras section of stencil.config has been renamed compat
experimentalSlotFixes (and slotChildNodesFix, scopedSlotTextContentFix, appendChildSlotFix)
experimentalSlotFixes: true used to set slotChildNodesFix, scopedSlotTextContentFix and appendChildSlotFix to true too ... but you could also set those individually?
This has now become:
lightDomPatches: boolean | {
slotChildNodes: boolean,
slotCloneNode: boolean,
slotDomMutations: boolean,
slotTextContent: boolean,
}Additionally:
lightDomPatchesistrueby default (but will only be bundled if you use lightDOM components with slots)insertAdjacentTextandinsertAdjacentElementpatched methods have been removed to save on runtime bytes.
enableImportInjection default
This option was previously opt-in. It's now opt-out.
Deprecated tagNameTransform, experimentalImportInjection, experimentalScopedSlotChanges
These deprecated options have now been removed.
Any unique functionality previously covered via experimentalScopedSlotChanges is now within lightDomPatches options.
Migration pathway
extras > compat and explicit *fix options can be auto-migrated by stencil migrate
BREAKING CHANGE - hashFile* settings moved from global to output targets options
hashFileNames and hashFileNameLength were top level configuration options, but they only really make sense for outputs that are:
- to be loaded directly in the browser / in a CDN and
- naturally have a single entry point
Therefore these options have been moved into the loader-bundle and www outputs.
Migration pathway
Any explicit hashFile* options can be auto migrated into appropriate output targets via stencil migrate
BREAKING CHANGE - collection importing / rebundling
In v4, importing any module / utility / type from a 3rd party node_module stencil lib would re-bundle the whole library.
Although this behaviour wasn't documented (documentation mentioned only importing as a sideEffect import "@ionic/core") users may have come to rely upon it.
Migration pathway
Users now have 2 options to re-bundle a 3rd party Stencil lib:
- The documented import from a stencil lib as a sideEffect -
import "@ionic/core" - A new top-level stencil.config option
collections: []has been added
BREAKING CHANGE - dist-custom-elements: externalRuntime (now standalone)
externalRuntime now defaults to false (was true). Component bundles are now self-contained - the runtime is included as a local shared chunk rather than left as an external @stencil/core/runtime/client import.
Setting externalRuntime: true is only recommended if you intend on having multiple Stencil component libraries (or components from different libraries) on the same page to limit the bytes sent to the end-user via multiple runtimes.
Migration pathway
Opt back in by setting externalRuntime: true. Run stencil migrate to remove any now-redundant externalRuntime: false from your config.
BREAKING CHANGE - www: serviceWorker
serviceWorker now defaults to null (was 'create Workbox-powered service worker' by default).
Migration pathway
Opt back in by setting serviceWorker: true for the auto-service worker creation.
Run stencil migrate to remove any now-redundant serviceWorker: null from your config.
BREAKING CHANGE - componentShouldUpdate Batching
componentShouldUpdate now fires once per render cycle instead of once per changed @Prop/@State. The callback signature changed from (newVal, oldVal, propName) to a single changes argument (a map of every prop/state name that changed since the last render to its { newVal, oldVal }).
@Prop() prop1;
componentShouldUpdate(changes) {
if (changes['prop1'].newVal === changes['prop1'].oldVal) {
return false;
}
}Alternatively, you can get stricter typing via ComponentShouldUpdateChanges
@Prop() prop1;
componentShouldUpdate(changes: ComponentShouldUpdateChanges<this>) {
if (changes['unknown'].newVal === changes['unknown'].oldVal) { // << will throw
return false;
}
}Migration pathway
Users should update any componentShouldUpdate methods to accept the single changes argument (optionally typed as ComponentShouldUpdateChanges<this> for per-prop typing) and 2) rewrite any per-prop branching logic to key off changes[propName] instead of the old propName parameter.
Additionally, a compiler warning will throw when more than one argument is passed to componentShouldUpdate
More TBD
This is a live document ... I'm adding more things as I work on them
[^1]: * I know this exists to allow for Jest to be updated however it has all of the same problems... but worse a) because core changes are not synced to the downstream package and b) has no e2e solution
Source: stenciljs/core