Skip to content

Commit add9245

Browse files
feat(runtime): expose ReactiveController and ReactiveControllerHost
Moves ReactiveController interface and ReactiveControllerHost class from test utilities to @stencil/core runtime, making them publicly available for component composition patterns. This enables developers to: - Create reusable reactive controllers that hook into component lifecycle - Compose multiple controllers in a single component - Share stateful logic across components without inheritance Components extending ReactiveControllerHost can use addController() to register controllers that automatically receive lifecycle callbacks (hostConnected, hostDisconnected, hostWillLoad, etc.) and can trigger updates via requestUpdate(). Known Limitation: Components extending ReactiveControllerHost cannot use <Host> as their root element since ReactiveControllerHost does not extend HTMLElement. Components must return regular elements (e.g., <div>) as root. Changes: - Add ReactiveController interface to src/runtime/reactive-controller.ts - Add ReactiveControllerHost class to src/runtime/reactive-controller.ts - Export from src/runtime/index.ts and src/hydrate/platform/index.ts - Add type declarations to src/declarations/stencil-public-runtime.ts - Update transformer to preserve ReactiveControllerHost in imports - Update test components to import from @stencil/core
1 parent 43608fa commit add9245

File tree

11 files changed

+169
-34
lines changed

11 files changed

+169
-34
lines changed

src/compiler/transformers/update-stencil-core-import.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,27 @@ export const updateStencilCoreImports = (updatedCoreImportPath: string): ts.Tran
1515
tsSourceFile.statements.forEach((s) => {
1616
if (ts.isImportDeclaration(s)) {
1717
if (s.moduleSpecifier != null && ts.isStringLiteral(s.moduleSpecifier)) {
18-
if (s.moduleSpecifier.text === STENCIL_CORE_ID) {
18+
const moduleSpecifierText = s.moduleSpecifier.text;
19+
20+
// Handle @stencil/core/jsx-runtime and @stencil/core/jsx-dev-runtime imports
21+
if (
22+
moduleSpecifierText === '@stencil/core/jsx-runtime' ||
23+
moduleSpecifierText === '@stencil/core/jsx-dev-runtime'
24+
) {
25+
// Rewrite to import from the updated core import path
26+
const newImport = ts.factory.updateImportDeclaration(
27+
s,
28+
s.modifiers,
29+
s.importClause,
30+
ts.factory.createStringLiteral(updatedCoreImportPath),
31+
s.attributes,
32+
);
33+
newStatements.push(newImport);
34+
madeChanges = true;
35+
return;
36+
}
37+
38+
if (moduleSpecifierText === STENCIL_CORE_ID) {
1939
if (
2040
s.importClause &&
2141
s.importClause.namedBindings &&
@@ -92,4 +112,8 @@ const KEEP_IMPORTS = new Set([
92112
'setTagTransformer',
93113
'transformTag',
94114
'Mixin',
115+
'jsx',
116+
'jsxs',
117+
'jsxDEV',
118+
'ReactiveControllerHost',
95119
]);

src/declarations/stencil-public-runtime.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,47 @@ export interface ComponentInterface {
638638
[memberName: string]: any;
639639
}
640640

641+
/**
642+
* Interface for reactive controllers that can be attached to a ReactiveControllerHost.
643+
* Controllers implement lifecycle hooks that are called by the host during component lifecycle.
644+
*/
645+
export interface ReactiveController {
646+
hostConnected?(): void;
647+
hostDisconnected?(): void;
648+
hostWillLoad?(): Promise<void> | void;
649+
hostDidLoad?(): void;
650+
hostWillRender?(): Promise<void> | void;
651+
hostDidRender?(): void;
652+
hostWillUpdate?(): Promise<void> | void;
653+
hostDidUpdate?(): void;
654+
}
655+
656+
/**
657+
* Base class that implements ComponentInterface and provides reactive controller functionality.
658+
* Components can extend this class to enable reactive controller composition.
659+
*
660+
* Known Limitation: Components extending ReactiveControllerHost cannot use
661+
* `<Host>` as their root element in the render method. This is because
662+
* ReactiveControllerHost does not extend HTMLElement. Instead, return a
663+
* regular element (like `<div>`) as the root.
664+
*/
665+
export declare class ReactiveControllerHost implements ComponentInterface {
666+
controllers: Set<ReactiveController>;
667+
668+
addController(controller: ReactiveController): void;
669+
removeController(controller: ReactiveController): void;
670+
requestUpdate(): void;
671+
connectedCallback(): void;
672+
disconnectedCallback(): void;
673+
componentWillLoad(): void;
674+
componentDidLoad(): void;
675+
componentWillRender(): void;
676+
componentDidRender(): void;
677+
componentWillUpdate(): void;
678+
componentDidUpdate(): void;
679+
[memberName: string]: any;
680+
}
681+
641682
// General types important to applications using stencil built components
642683
export interface EventEmitter<T = any> {
643684
emit: (data?: T) => CustomEvent<T>;

src/hydrate/platform/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export {
229229
postUpdateComponent,
230230
proxyComponent,
231231
proxyCustomElement,
232+
ReactiveControllerHost,
232233
renderVdom,
233234
setAssetPath,
234235
setMode,

src/runtime/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { setNonce } from './nonce';
1313
export { parsePropertyValue } from './parse-property-value';
1414
export { setPlatformOptions } from './platform-options';
1515
export { proxyComponent } from './proxy-component';
16+
export { ReactiveController, ReactiveControllerHost } from './reactive-controller';
1617
export { render } from './render';
1718
export { HYDRATED_STYLE_ID } from './runtime-constants';
1819
export { getValue, setValue } from './set-value';

src/runtime/reactive-controller.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { ComponentInterface } from '../declarations/stencil-public-runtime';
2+
import { forceUpdate } from './update-component';
3+
4+
export interface ReactiveController {
5+
hostConnected?(): void;
6+
hostDisconnected?(): void;
7+
hostWillLoad?(): Promise<void> | void;
8+
hostDidLoad?(): void;
9+
hostWillRender?(): Promise<void> | void;
10+
hostDidRender?(): void;
11+
hostWillUpdate?(): Promise<void> | void;
12+
hostDidUpdate?(): void;
13+
}
14+
15+
/**
16+
* Base class for components that want to use reactive controllers.
17+
*
18+
* Components extending this class can use the composition pattern to share
19+
* stateful logic via reactive controllers.
20+
*
21+
* Known Limitation: Components extending ReactiveControllerHost cannot use
22+
* `<Host>` as their root element in the render method. This is because
23+
* ReactiveControllerHost does not extend HTMLElement. Instead, return a
24+
* regular element (like `<div>`) as the root.
25+
*
26+
* @example
27+
* ```tsx
28+
* @Component({ tag: 'my-component' })
29+
* export class MyComponent extends ReactiveControllerHost {
30+
* private myController = new MyController(this);
31+
*
32+
* render() {
33+
* return <div>...</div>; // Use <div>, not <Host>
34+
* }
35+
* }
36+
* ```
37+
*/
38+
export class ReactiveControllerHost implements ComponentInterface {
39+
controllers = new Set<ReactiveController>();
40+
41+
addController(controller: ReactiveController) {
42+
this.controllers.add(controller);
43+
}
44+
45+
removeController(controller: ReactiveController) {
46+
this.controllers.delete(controller);
47+
}
48+
49+
requestUpdate() {
50+
forceUpdate(this);
51+
}
52+
53+
connectedCallback() {
54+
this.controllers.forEach((controller) => controller.hostConnected?.());
55+
}
56+
57+
disconnectedCallback() {
58+
this.controllers.forEach((controller) => controller.hostDisconnected?.());
59+
}
60+
61+
componentWillLoad() {
62+
this.controllers.forEach((controller) => controller.hostWillLoad?.());
63+
}
64+
65+
componentDidLoad() {
66+
this.controllers.forEach((controller) => controller.hostDidLoad?.());
67+
}
68+
69+
componentWillRender() {
70+
this.controllers.forEach((controller) => controller.hostWillRender?.());
71+
}
72+
73+
componentDidRender() {
74+
this.controllers.forEach((controller) => controller.hostDidRender?.());
75+
}
76+
77+
componentWillUpdate() {
78+
this.controllers.forEach((controller) => controller.hostWillUpdate?.());
79+
}
80+
81+
componentDidUpdate() {
82+
this.controllers.forEach((controller) => controller.hostDidUpdate?.());
83+
}
84+
}

test/wdio/ts-target/components.d.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,6 @@ export namespace Components {
307307
*/
308308
interface ExtendsRender {
309309
}
310-
interface ExtendsViaHostCmp {
311-
}
312310
/**
313311
* WatchCmp - Demonstrates
314312
* @Watch decorator inheritance
@@ -606,12 +604,6 @@ declare global {
606604
prototype: HTMLExtendsRenderElement;
607605
new (): HTMLExtendsRenderElement;
608606
};
609-
interface HTMLExtendsViaHostCmpElement extends Components.ExtendsViaHostCmp, HTMLStencilElement {
610-
}
611-
var HTMLExtendsViaHostCmpElement: {
612-
prototype: HTMLExtendsViaHostCmpElement;
613-
new (): HTMLExtendsViaHostCmpElement;
614-
};
615607
/**
616608
* WatchCmp - Demonstrates
617609
* @Watch decorator inheritance
@@ -708,7 +700,6 @@ declare global {
708700
"extends-mixin-cmp": HTMLExtendsMixinCmpElement;
709701
"extends-props-state": HTMLExtendsPropsStateElement;
710702
"extends-render": HTMLExtendsRenderElement;
711-
"extends-via-host-cmp": HTMLExtendsViaHostCmpElement;
712703
"extends-watch": HTMLExtendsWatchElement;
713704
"inheritance-checkbox-group": HTMLInheritanceCheckboxGroupElement;
714705
"inheritance-radio-group": HTMLInheritanceRadioGroupElement;
@@ -906,8 +897,6 @@ declare namespace LocalJSX {
906897
*/
907898
interface ExtendsRender {
908899
}
909-
interface ExtendsViaHostCmp {
910-
}
911900
/**
912901
* WatchCmp - Demonstrates
913902
* @Watch decorator inheritance
@@ -986,7 +975,6 @@ declare namespace LocalJSX {
986975
"extends-mixin-cmp": ExtendsMixinCmp;
987976
"extends-props-state": ExtendsPropsState;
988977
"extends-render": ExtendsRender;
989-
"extends-via-host-cmp": ExtendsViaHostCmp;
990978
"extends-watch": ExtendsWatch;
991979
"inheritance-checkbox-group": InheritanceCheckboxGroup;
992980
"inheritance-radio-group": InheritanceRadioGroup;
@@ -1071,7 +1059,6 @@ declare module "@stencil/core" {
10711059
* - CSS Class Inheritance: CSS classes from parent template maintained in component extension
10721060
*/
10731061
"extends-render": LocalJSX.ExtendsRender & JSXBase.HTMLAttributes<HTMLExtendsRenderElement>;
1074-
"extends-via-host-cmp": LocalJSX.ExtendsViaHostCmp & JSXBase.HTMLAttributes<HTMLExtendsViaHostCmpElement>;
10751062
/**
10761063
* WatchCmp - Demonstrates
10771064
* @Watch decorator inheritance

test/wdio/ts-target/extends-composition-scaling/checkbox-group-cmp.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { Component, h, State, Element, Event, EventEmitter } from '@stencil/core';
2-
import { ReactiveControllerHost } from './reactive-controller-host.js';
1+
import { Component, h, ReactiveControllerHost, State, Element, Event, EventEmitter } from '@stencil/core';
32
import { ValidationController } from './validation-controller.js';
43
import { FocusController } from './focus-controller.js';
54

@@ -15,7 +14,7 @@ export class CheckboxGroupCmp extends ReactiveControllerHost {
1514

1615
// Controllers via composition
1716
private validation = new ValidationController(this);
18-
private focus = new FocusController(this);
17+
private focusController = new FocusController(this);
1918

2019
private inputId = `checkbox-group-${Math.random().toString(36).substr(2, 9)}`;
2120
private helperTextId = `${this.inputId}-helper-text`;
@@ -55,16 +54,16 @@ export class CheckboxGroupCmp extends ReactiveControllerHost {
5554
};
5655

5756
private handleFocus = () => {
58-
this.focus.handleFocus();
57+
this.focusController.handleFocus();
5958
};
6059

6160
private handleBlur = () => {
62-
this.focus.handleBlur();
61+
this.focusController.handleBlur();
6362
this.validation.handleBlur(this.values);
6463
};
6564

6665
render() {
67-
const focusState = this.focus.getFocusState();
66+
const focusState = this.focusController.getFocusState();
6867
const validationData = this.validation.getValidationMessageData(this.helperTextId, this.errorTextId);
6968

7069
return (

test/wdio/ts-target/extends-composition-scaling/focus-controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* 3. Provides methods to handle focus lifecycle
88
*/
99
import { forceUpdate } from '@stencil/core';
10-
import type { ReactiveControllerHost, ReactiveController } from './reactive-controller-host.js';
10+
import type { ReactiveControllerHost, ReactiveController } from '@stencil/core';
1111

1212
export class FocusController implements ReactiveController {
1313
private host: ReactiveControllerHost;

test/wdio/ts-target/extends-composition-scaling/radio-group-cmp.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { Component, h, State, Element, Event, EventEmitter } from '@stencil/core';
2-
import { ReactiveControllerHost } from './reactive-controller-host.js';
1+
import { Component, h, ReactiveControllerHost, State, Element, Event, EventEmitter } from '@stencil/core';
32
import { ValidationController } from './validation-controller.js';
43
import { FocusController } from './focus-controller.js';
54

@@ -15,7 +14,7 @@ export class RadioGroupCmp extends ReactiveControllerHost {
1514

1615
// Controllers via composition
1716
private validation = new ValidationController(this);
18-
private focus = new FocusController(this);
17+
private focusController = new FocusController(this);
1918

2019
private inputId = `radio-group-${Math.random().toString(36).substr(2, 9)}`;
2120
private helperTextId = `${this.inputId}-helper-text`;
@@ -50,16 +49,16 @@ export class RadioGroupCmp extends ReactiveControllerHost {
5049
};
5150

5251
private handleFocus = () => {
53-
this.focus.handleFocus();
52+
this.focusController.handleFocus();
5453
};
5554

5655
private handleBlur = () => {
57-
this.focus.handleBlur();
56+
this.focusController.handleBlur();
5857
this.validation.handleBlur(this.value);
5958
};
6059

6160
render() {
62-
const focusState = this.focus.getFocusState();
61+
const focusState = this.focusController.getFocusState();
6362
const validationData = this.validation.getValidationMessageData(this.helperTextId, this.errorTextId);
6463

6564
return (

test/wdio/ts-target/extends-composition-scaling/text-input-cmp.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { Component, h, State, Element } from '@stencil/core';
2-
import { ReactiveControllerHost } from './reactive-controller-host.js';
1+
import { Component, h, ReactiveControllerHost, State, Element } from '@stencil/core';
32
import { ValidationController } from './validation-controller.js';
43
import { FocusController } from './focus-controller.js';
54

@@ -13,7 +12,7 @@ export class TextInputCmp extends ReactiveControllerHost {
1312

1413
// Controllers via composition
1514
private validation = new ValidationController(this);
16-
private focus = new FocusController(this);
15+
private focusController = new FocusController(this);
1716

1817
private inputId = `text-input-${Math.random().toString(36).substr(2, 9)}`;
1918
private helperTextId = `${this.inputId}-helper-text`;
@@ -47,16 +46,16 @@ export class TextInputCmp extends ReactiveControllerHost {
4746
};
4847

4948
private handleFocus = () => {
50-
this.focus.handleFocus();
49+
this.focusController.handleFocus();
5150
};
5251

5352
private handleBlur = () => {
54-
this.focus.handleBlur();
53+
this.focusController.handleBlur();
5554
this.validation.handleBlur(this.value);
5655
};
5756

5857
render() {
59-
const focusState = this.focus.getFocusState();
58+
const focusState = this.focusController.getFocusState();
6059
const validationState = this.validation.getValidationState();
6160
const validationData = this.validation.getValidationMessageData(this.helperTextId, this.errorTextId);
6261

0 commit comments

Comments
 (0)