Skip to content

Commit 313973d

Browse files
committed
chore: tighten types. Make route for ReactiveController elements to work with lazy output. Test both outputs with lit/context
1 parent 7c325c8 commit 313973d

10 files changed

Lines changed: 155 additions & 65 deletions

File tree

packages/core/src/declarations/stencil-public-runtime.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -825,10 +825,7 @@ export interface ReactiveController {
825825
hostDidUpdate?(): void;
826826
}
827827

828-
/**
829-
* The shape added to a component by mixing in `ReactiveControllerHost` (see below).
830-
*/
831-
export interface ReactiveControllerHostInterface extends ComponentInterface {
828+
interface ReactiveControllerHost extends ComponentInterface {
832829
readonly controllers: ReadonlySet<ReactiveController>;
833830
addController(controller: ReactiveController): void;
834831
removeController(controller: ReactiveController): void;
@@ -841,6 +838,11 @@ export interface ReactiveControllerHostInterface extends ComponentInterface {
841838
readonly updateComplete: Promise<boolean>;
842839
}
843840

841+
/**
842+
* The shape added to a component by mixing in `ReactiveControllerHost` (see below).
843+
*/
844+
export type ReactiveControllerHostInterface = ReactiveControllerHost & HTMLElement;
845+
844846
/**
845847
* A mixin factory (for use with `Mixin()`) that adds `ReactiveController` support to a component,
846848
* forwarding each Stencil lifecycle method to every registered controller's matching `hostX` hook.

packages/core/src/runtime/_test_/reactive-controller.spec.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,4 +247,31 @@ describe('reactive-controller', () => {
247247
await expect(pending).resolves.toBe(true);
248248
expect(renderCount).toBe(2);
249249
});
250+
251+
it('fires hostConnected immediately for a controller added after the host already connected', async () => {
252+
const calls: string[] = [];
253+
let host: any;
254+
255+
@Component({ tag: 'rc-late-controller' })
256+
class Cmp extends Mixin(ReactiveControllerHost) {
257+
constructor() {
258+
super();
259+
host = this;
260+
}
261+
render() {
262+
return <div />;
263+
}
264+
}
265+
266+
await newSpecPage({ components: [Cmp], html: `<rc-late-controller></rc-late-controller>` });
267+
268+
class LateController implements ReactiveController {
269+
hostConnected() {
270+
calls.push('hostConnected');
271+
}
272+
}
273+
host.addController(new LateController());
274+
275+
expect(calls).toEqual(['hostConnected']);
276+
});
250277
});

packages/core/src/runtime/reactive-controller.ts

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
1+
import { getElement } from './element';
12
import { forceUpdate } from './update-component';
23
import type {
34
ComponentInterface,
45
ReactiveController,
56
ReactiveControllerHostInterface,
7+
MixedInCtor,
68
} from '../declarations/stencil-public-runtime';
79

8-
type Ctor<T = {}> = new (...args: any[]) => T;
9-
10-
// Explicit return type: the class below has a #private field, which the dts bundler can't
11-
// describe for an exported function's inferred (anonymous) return type (TS4094).
12-
export const ReactiveControllerHost = <B extends Ctor<ComponentInterface>>(
10+
export const ReactiveControllerHost = <B extends MixedInCtor<ComponentInterface & HTMLElement>>(
1311
Base: B,
14-
): Ctor<InstanceType<B> & ReactiveControllerHostInterface> => {
12+
): B & MixedInCtor<ReactiveControllerHostInterface> =>
1513
class ReactiveControllerHostMixin extends Base implements ReactiveControllerHostInterface {
1614
controllers = new Set<ReactiveController>();
15+
#connected = false;
1716
#updateCompleteResolvers: Array<(value: boolean) => void> = [];
1817

1918
addController(controller: ReactiveController) {
2019
this.controllers.add(controller);
20+
// Matches Lit's ReactiveElement: a controller added after the host is already connected
21+
// (e.g. constructed from a lifecycle hook rather than a field initializer - needed for any
22+
// controller that wants a real DOM element, see connectedCallback below) would otherwise
23+
// never see hostConnected - the bulk connectedCallback pass below already ran without it.
24+
if (this.#connected) {
25+
controller.hostConnected?.();
26+
}
2127
}
2228

2329
removeController(controller: ReactiveController) {
@@ -34,11 +40,31 @@ export const ReactiveControllerHost = <B extends Ctor<ComponentInterface>>(
3440

3541
connectedCallback() {
3642
super.connectedCallback?.();
43+
this.#connected = true;
44+
45+
// Under lazy-loading, `this` (the lazy instance) and the real host element are different
46+
// objects - only `this` has addController/removeController/requestUpdate/updateComplete. A
47+
// controller that needs genuine DOM access (addEventListener/dispatchEvent, e.g.
48+
// @lit/context) needs a single object with both capabilities; bridge them onto the real
49+
// element here so `@Element()`/`getElement(this)` works uniformly across build targets. In
50+
// a standalone build getElement(this) === this, so this is a no-op there.
51+
const el = getElement(this) as any;
52+
if (el && el !== (this as unknown)) {
53+
el.addController = (controller: ReactiveController) => this.addController(controller);
54+
el.removeController = (controller: ReactiveController) => this.removeController(controller);
55+
el.requestUpdate = () => this.requestUpdate();
56+
Object.defineProperty(el, 'updateComplete', {
57+
configurable: true,
58+
get: () => this.updateComplete,
59+
});
60+
}
61+
3762
this.controllers.forEach((c) => c.hostConnected?.());
3863
}
3964

4065
disconnectedCallback() {
4166
super.disconnectedCallback?.();
67+
this.#connected = false;
4268
this.controllers.forEach((c) => c.hostDisconnected?.());
4369
}
4470

@@ -74,10 +100,4 @@ export const ReactiveControllerHost = <B extends Ctor<ComponentInterface>>(
74100
super.componentDidUpdate?.();
75101
this.controllers.forEach((c) => c.hostDidUpdate?.());
76102
}
77-
}
78-
// TS can't verify a generically-extended class satisfies InstanceType<B> - same pattern used
79-
// by other mixin factories in this codebase (e.g. test/runtime's mixin-factories.ts).
80-
return ReactiveControllerHostMixin as unknown as Ctor<
81-
InstanceType<B> & ReactiveControllerHostInterface
82-
>;
83-
};
103+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<!doctype html>
2+
<html dir="ltr" lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>lit-context interop</title>
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
8+
<!-- lazy-loaded build (loader-bundle output target), default tag names -->
9+
<script type="module" src="/dist/loader-bundle/app/app.js"></script>
10+
11+
<!-- standalone build (dist-custom-elements), tag names prefixed so it can be verified on the
12+
same page without colliding with the lazy-loaded custom elements above -->
13+
<script type="module">
14+
import { setTagTransformer } from '/dist/standalone/index.js';
15+
setTagTransformer((tag) => `standalone-${tag}`);
16+
const provider = await import('/dist/standalone/lit-context-provider.js');
17+
const consumer = await import('/dist/standalone/lit-context-consumer.js');
18+
provider.defineCustomElement();
19+
consumer.defineCustomElement();
20+
</script>
21+
</head>
22+
23+
<body>
24+
<lit-context-provider>
25+
<lit-context-consumer></lit-context-consumer>
26+
</lit-context-provider>
27+
28+
<standalone-lit-context-provider>
29+
<standalone-lit-context-consumer></standalone-lit-context-consumer>
30+
</standalone-lit-context-provider>
31+
</body>
32+
</html>

test/integration/lit-context/package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@
1313
"build": "stencil build",
1414
"build.dev": "stencil build --dev",
1515
"start": "stencil build --dev --watch --serve",
16-
"test": "stencil-test && playwright test",
17-
"test.e2e": "playwright test",
18-
"test.spec": "stencil-test",
19-
"generate": "stencil generate"
16+
"test": "playwright test"
2017
},
2118
"dependencies": {
2219
"@lit/context": "^1.1.6",

test/integration/lit-context/src/components/lit-context-consumer/lit-context-consumer.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
import { ContextConsumer } from '@lit/context';
2-
import { Component, Mixin, ReactiveControllerHost, State } from '@stencil/core';
2+
import {
3+
Component,
4+
Element,
5+
Mixin,
6+
ReactiveControllerHost,
7+
ReactiveControllerHostInterface,
8+
State,
9+
} from '@stencil/core';
310

411
import { greetingContext } from '../../lit-context.js';
512

613
@Component({ tag: 'lit-context-consumer' })
714
export class LitContextConsumer extends Mixin(ReactiveControllerHost) {
815
@State() value?: string;
16+
@Element() host!: typeof this;
917

10-
private consumer = new ContextConsumer(this, {
11-
context: greetingContext,
12-
callback: (value) => {
13-
this.value = value;
14-
},
15-
});
18+
private consumer?: ContextConsumer<typeof greetingContext, ReactiveControllerHostInterface>;
19+
20+
connectedCallback() {
21+
super.connectedCallback?.();
22+
// constructed here, not as a field initializer: the real host element (with
23+
// addController/etc. bridged onto it) is only available once connected - see
24+
// reactive-controller.ts's connectedCallback for why.
25+
this.consumer ??= new ContextConsumer(this.host, {
26+
context: greetingContext,
27+
callback: (value) => {
28+
this.value = value;
29+
},
30+
});
31+
}
1632

1733
render() {
1834
return <div class='value'>{this.value ?? 'no value'}</div>;

test/integration/lit-context/src/components/lit-context-provider/lit-context-provider.e2e.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@ import { test } from '@stencil/playwright';
44
/**
55
* Real-world interop check: @lit/context's ContextProvider/ContextConsumer, composed onto
66
* Stencil components via `Mixin(ReactiveControllerHost)`, propagating a value across a real
7-
* `context-request` DOM event in an actual browser (dist-custom-elements, since that's what a
8-
* real consumer imports).
7+
* `context-request` DOM event in an actual browser. The page loads both the lazy-loaded (`dist`)
8+
* and standalone (`dist-custom-elements`) builds side by side - the standalone half is registered
9+
* under `standalone-`-prefixed tag names (via `setTagTransformer`) so both can run without
10+
* colliding - since a controller that needs real DOM access only works correctly in one of them
11+
* without the mixin's host-element bridging (see reactive-controller.ts's `connectedCallback`).
912
*/
1013
test.describe('lit-context interop', () => {
11-
test('propagates a value from provider to consumer via a real context-request event', async ({
12-
page,
13-
}) => {
14-
await page.goto('/');
14+
for (const [label, prefix] of [
15+
['lazy', ''],
16+
['standalone', 'standalone-'],
17+
] as const) {
18+
test(`propagates a value from provider to consumer via a real context-request event (${label})`, async ({
19+
page,
20+
}) => {
21+
await page.goto('/');
1522

16-
const consumer = page.locator('lit-context-consumer .value');
17-
await expect(consumer).toHaveText('hello from provider');
18-
});
23+
const consumer = page.locator(`${prefix}lit-context-consumer .value`);
24+
await expect(consumer).toHaveText('hello from provider');
25+
});
26+
}
1927
});

test/integration/lit-context/src/components/lit-context-provider/lit-context-provider.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
import { ContextProvider } from '@lit/context';
2-
import { Component, Host, Mixin, ReactiveControllerHost } from '@stencil/core';
2+
import { Component, Host, Mixin, ReactiveControllerHost, Element } from '@stencil/core';
33

44
import { greetingContext } from '../../lit-context.js';
55

66
@Component({ tag: 'lit-context-provider' })
77
export class LitContextProvider extends Mixin(ReactiveControllerHost) {
8-
private provider = new ContextProvider(this, {
9-
context: greetingContext,
10-
initialValue: 'hello from provider',
11-
});
8+
@Element() host!: typeof this;
9+
private provider?: ContextProvider<typeof greetingContext>;
10+
11+
connectedCallback() {
12+
super.connectedCallback?.();
13+
// constructed here, not as a field initializer: the real host element (with
14+
// addController/etc. bridged onto it) is only available once connected - see
15+
// reactive-controller.ts's connectedCallback for why.
16+
this.provider ??= new ContextProvider(this.host, {
17+
context: greetingContext,
18+
initialValue: 'hello from provider',
19+
});
20+
}
1221

1322
render() {
1423
return (

test/integration/lit-context/src/index.html

Lines changed: 0 additions & 17 deletions
This file was deleted.

test/integration/lit-context/stencil.config.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@ export const config: Config = {
55
devServer: { port: 3336 },
66
tsconfig: 'tsconfig.stencil.json',
77
outputTargets: [
8-
{ type: 'www', hashFileNames: false },
9-
{
10-
type: 'standalone',
11-
dir: 'www/build/standalone',
12-
customElementsExportBehavior: 'auto-define-custom-elements',
13-
},
8+
{ type: 'loader-bundle', skipInDev: false },
9+
{ type: 'standalone', skipInDev: false },
1410
],
1511
};

0 commit comments

Comments
 (0)