Skip to content

v5 Breaking Changes Proposals / Discussion #6584

Description

@johnjenkins

(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 v291
  • Stencil's --spec tests 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:
    1. it reaches into Stencil's internals to work which is prone to error and is fundamentally not representative of the final output.
    2. is very difficult to make work with classes that can now use extends (optionally in combination with Mixin)
    3. doesn't give devs the option to use their 'node dom' of choice (e.g. happy-dom, JSDOM)
  • Stencil's --e2e tests 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 --spec style 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 --e2e style 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-open anymore.
  • Pass in --open (in the cli) or openBrowser to the devServer config 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-script to ssr (read about all core output targets renamed below).
  • The output no-longer writes a package.json file
  • The default hydrate.js is now esm whilst commonJS is opt-in (via cjs: true) and outputs as hydrate.cjs
  • The exported hydrateDocument has been renamed to ssrDocument (hydrateDocument is still exported as deprecated for now)
  • All config options with *hydrate (beforeHydrate / afterHydrate) have been renamed to *ssr (*hydrate are still exported as deprecated options for now)
  • To make the output environment agnostic (not just Node.js) the streamToString() return type has been changed from Node.js Readable to web-standard ReadableStream<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

  1. not particularly clear in intent
  2. pretty nuclear (e.g. users are forced via buildDocs to build ALL docs outputs during dev)
  3. In the case of buildDist is 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 was dist now dist/loader-bundle
  • dist-custom-elements > standalone. Default output dir was custom-elements now is dist/standalone
  • dist-hydrate-script > ssr. Default output dir was hydrate now is dist/ssr
  • new (optional) types output. Customisable with skipInDev and dir options - defaults to dist/types. Is auto-generated in production builds.
  • new (optional) collection is now an explicit output. Customisable with skipInDev and dir options - defaults to dist/collection. Is auto-generated in production builds.
  • dist.typesDir removed. Use types.dir
  • dist.collectionsDir removed. Use collection.dir
  • dist-custom-elements.isPrimaryPackageOutputTarget removed. Choose your own default export in package.json (cli hints will guide you to choose one depending on your outputs).
  • dist-custom-elements.generateTypeDeclarations removed. Auto generated and written to types.dir
  • dist.esmLoaderPath renamed to loaderPath. Path is now calculated from dist/loader-bundle instead of dist (so use loaderPath: '../' 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 --prod cli flag has been removed (was redundant as prod builds are the default without explicit --dev flag)
  • The devMode config 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:

  • lightDomPatches is true by default (but will only be bundled if you use lightDOM components with slots)
  • insertAdjacentText and insertAdjacentElement patched 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:

  1. to be loaded directly in the browser / in a CDN and
  2. 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:

  1. The documented import from a stencil lib as a sideEffect - import "@ionic/core"
  2. 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.

More TBD

This is a live document ... I'm adding more things as I work on them

Footnotes

    • 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions