Skip to content

Commit

Permalink
fix(runtime): throw proper error if component is loaded with invalid …
Browse files Browse the repository at this point in the history
…runtime (#5675)

* fix(runtime): throw proper error if component is loaded with invalid runtime

* update comments

* prettier

* only run new test in Chromium

* PR feedback
  • Loading branch information
christian-bromann authored May 14, 2024
1 parent 3a33eff commit 3cfbb8d
Show file tree
Hide file tree
Showing 14 changed files with 168 additions and 41 deletions.
19 changes: 19 additions & 0 deletions src/runtime/update-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ const dispatchHooks = (hostRef: d.HostRef, isInitialLoad: boolean): Promise<void
const endSchedule = createTime('scheduleUpdate', hostRef.$cmpMeta$.$tagName$);
const instance = BUILD.lazyLoad ? hostRef.$lazyInstance$ : elm;

/**
* Given a user imports a component compiled with a `dist-custom-element`
* output target into a Stencil project compiled with a `dist` output target,
* then `instance` will be `undefined` as `hostRef` won't have a `lazyInstance`
* property. In this case, the component will fail to render in one of the
* subsequent functions.
*
* For this scenario to work the user needs to set the `externalRuntime` flag
* for the `dist-custom-element` component that is being imported into the `dist`
* Stencil project.
*/
if (!instance) {
throw new Error(
`Can't render component <${elm.tagName.toLowerCase()} /> with invalid Stencil runtime! ` +
'Make sure this imported component is compiled with a `externalRuntime: true` flag. ' +
'For more information, please refer to https://stenciljs.com/docs/custom-elements#externalruntime',
);
}

// We're going to use this variable together with `enqueue` to implement a
// little promise-based queue. We start out with it `undefined`. When we add
// the first function to the queue we'll set this variable to be that
Expand Down
29 changes: 29 additions & 0 deletions test/wdio/global-script/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,60 @@
*/
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
export namespace Components {
/**
* rendering this component will fail as `<attribute-basic /`> is compiled with
* a `dist-custom-element` output target, which will break in a lazy load environment
*/
interface GlobalScriptDistCmp {
}
interface GlobalScriptTestCmp {
}
}
declare global {
/**
* rendering this component will fail as `<attribute-basic /`> is compiled with
* a `dist-custom-element` output target, which will break in a lazy load environment
*/
interface HTMLGlobalScriptDistCmpElement extends Components.GlobalScriptDistCmp, HTMLStencilElement {
}
var HTMLGlobalScriptDistCmpElement: {
prototype: HTMLGlobalScriptDistCmpElement;
new (): HTMLGlobalScriptDistCmpElement;
};
interface HTMLGlobalScriptTestCmpElement extends Components.GlobalScriptTestCmp, HTMLStencilElement {
}
var HTMLGlobalScriptTestCmpElement: {
prototype: HTMLGlobalScriptTestCmpElement;
new (): HTMLGlobalScriptTestCmpElement;
};
interface HTMLElementTagNameMap {
"global-script-dist-cmp": HTMLGlobalScriptDistCmpElement;
"global-script-test-cmp": HTMLGlobalScriptTestCmpElement;
}
}
declare namespace LocalJSX {
/**
* rendering this component will fail as `<attribute-basic /`> is compiled with
* a `dist-custom-element` output target, which will break in a lazy load environment
*/
interface GlobalScriptDistCmp {
}
interface GlobalScriptTestCmp {
}
interface IntrinsicElements {
"global-script-dist-cmp": GlobalScriptDistCmp;
"global-script-test-cmp": GlobalScriptTestCmp;
}
}
export { LocalJSX as JSX };
declare module "@stencil/core" {
export namespace JSX {
interface IntrinsicElements {
/**
* rendering this component will fail as `<attribute-basic /`> is compiled with
* a `dist-custom-element` output target, which will break in a lazy load environment
*/
"global-script-dist-cmp": LocalJSX.GlobalScriptDistCmp & JSXBase.HTMLAttributes<HTMLGlobalScriptDistCmpElement>;
"global-script-test-cmp": LocalJSX.GlobalScriptTestCmp & JSXBase.HTMLAttributes<HTMLGlobalScriptTestCmpElement>;
}
}
Expand Down
19 changes: 19 additions & 0 deletions test/wdio/global-script/dist-cmp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Component, h } from '@stencil/core';

/**
* rendering this component will fail as `<attribute-basic /`> is compiled with
* a `dist-custom-element` output target, which will break in a lazy load environment
*/
@Component({
tag: 'global-script-dist-cmp',
scoped: true,
})
export class GlobalScriptDistCmp {
render() {
return (
<section>
<attribute-basic></attribute-basic>
</section>
);
}
}
27 changes: 27 additions & 0 deletions test/wdio/global-script/global-script.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { h } from '@stencil/core';
import { render } from '@wdio/browser-runner/stencil';

import { setupIFrameTest } from '../util.js';

describe('global script', () => {
beforeEach(() => {
render({
Expand All @@ -15,4 +17,29 @@ describe('global script', () => {
const renderedDelay = parseInt(text.slice('I am rendered after '.length));
expect(renderedDelay).toBeGreaterThanOrEqual(1000);
});

it('logs error when component with invalid runtime is loaded', async () => {
/**
* Fetching logs like this only works in Chromium. Once WebdriverIO v9 is released there
* will be easier primitives to fetch logs in other browsers as well.
*/
if (!browser.isChromium) {
console.warn('Skipping test because it only works in Chromium');
return;
}

await setupIFrameTest('/global-script/index.html');

const expectedErrorMessage = `Can't render component <attribute-basic /> with invalid Stencil runtime!`;
await browser.waitUntil(
async () => {
const logs = (await browser.getLogs('browser')) as { message: string }[];
expect(logs.find((log) => log.message.includes(expectedErrorMessage))).toBeTruthy();
return true;
},
{
timeoutMsg: 'Expected error message not found in console logs.',
},
);
});
});
11 changes: 11 additions & 0 deletions test/wdio/global-script/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ declare global {
}
}

import { defineCustomElements } from '../test-components/index.js';

export default async function () {
window.__testStart = Date.now();

/**
* import components from the test-components package which are build using
* the `dist-custom-element` output target to validate if the rendering fails
* with proper error message as the global-script project is run within a
* lazy load environment.
*/
defineCustomElements();

return new Promise((resolve) => setTimeout(() => resolve('done!'), 1000));
}
11 changes: 11 additions & 0 deletions test/wdio/global-script/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8">
<title>Stencil Starter App</title>
<script type="module" src="/www-global-script/build/testglobalscript.esm.js"></script>
</head>
<body>
<global-script-dist-cmp></global-script-dist-cmp>
</body>
</html>
8 changes: 6 additions & 2 deletions test/wdio/global-script/test-cmp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import { Component, h } from '@stencil/core';
tag: 'global-script-test-cmp',
scoped: true,
})
export class SiblingRoot {
export class GlobalScriptTestCmp {
render() {
return <div>I am rendered after {Date.now() - window.__testStart}</div>;
return (
<section>
<div>I am rendered after {Date.now() - window.__testStart}</div>
</section>
);
}
}
2 changes: 1 addition & 1 deletion test/wdio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"type": "module",
"version": "0.0.0",
"scripts": {
"build": "run-s build.global-script build.test-sibling build.main build.prerender build.invisible-prehydration",
"build": "run-s build.test-sibling build.main build.global-script build.prerender build.invisible-prehydration",
"build.main": "node ../../bin/stencil build --debug --es5",
"build.global-script": "node ../../bin/stencil build --debug --es5 --config global-script.stencil.config.ts",
"build.test-sibling": "cd test-sibling && npm run build",
Expand Down
4 changes: 3 additions & 1 deletion test/wdio/ref-attr-order/cmp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export class RefAttrOrder {
return (
<div
ref={(el) => {
this.index = el.tabIndex;
if (el) {
this.index = el.tabIndex;
}
}}
tabIndex={0}
>
Expand Down
3 changes: 2 additions & 1 deletion test/wdio/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ declare global {
const testRequiresManualSetup =
window.__wdioSpec__.includes('custom-elements-output-tag-class-different') ||
window.__wdioSpec__.includes('custom-elements-delegates-focus') ||
window.__wdioSpec__.includes('custom-elements-output');
window.__wdioSpec__.includes('custom-elements-output') ||
window.__wdioSpec__.includes('global-script');

/**
* setup all components defined in tests except for those where we want ot manually setup
Expand Down
36 changes: 2 additions & 34 deletions test/wdio/slot-ng-if/cmp.test.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,11 @@
import './assets/angular.min.js';

/**
* Note: this file is meant to be run in the browser, not in Node.js. WebdriverIO
* injects some basic polyfills for Node.js to make the following possible.
*/
import path from 'node:path';

async function setupTest(htmlFile: string): Promise<HTMLElement> {
if (document.querySelector('iframe')) {
document.body.removeChild(document.querySelector('iframe'));
}

const htmlFilePath = path.resolve(
path.dirname(globalThis.__wdioSpec__),
'..',
htmlFile.slice(htmlFile.startsWith('/') ? 1 : 0),
);
const iframe = document.createElement('iframe');

/**
* Note: prefixes the absolute path to the html file with `/@fs` is a ViteJS (https://vitejs.dev/)
* feature which allows to serve static content from files this way
*/
iframe.src = `/@fs${htmlFilePath}`;
iframe.width = '600px';
iframe.height = '600px';
document.body.appendChild(iframe);

/**
* wait for the iframe to load
*/
await new Promise((resolve) => (iframe.onload = resolve));
return iframe.contentDocument.body;
}
import { setupIFrameTest } from '../util.js';

describe('slot-ng-if', () => {
let iframe: HTMLElement;
before(async () => {
iframe = await setupTest('/slot-ng-if/index.html');
iframe = await setupIFrameTest('/slot-ng-if/index.html');
});

it('renders bound values in slots within ng-if context', async () => {
Expand Down
2 changes: 2 additions & 0 deletions test/wdio/stencil.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const config: Config = {
{
type: 'dist-custom-elements',
dir: 'test-components',
customElementsExportBehavior: 'bundle',
isPrimaryPackageOutputTarget: true,
},
],
plugins: [sass()],
Expand Down
4 changes: 2 additions & 2 deletions test/wdio/test-prerender/src/global/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ export function printLifecycle(cmp: string, lifecycle: string) {
if (Build.isBrowser) {
const output = document.getElementById(`client-${lifecycle}`);
elm.textContent = `${cmp} client ${lifecycle}`;
output.appendChild(elm);
output?.appendChild(elm);
} else {
const output = document.getElementById(`server-${lifecycle}`);
elm.textContent = `${cmp} server ${lifecycle}`;
output.appendChild(elm);
output?.appendChild(elm);
}
}
34 changes: 34 additions & 0 deletions test/wdio/util.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,41 @@
/**
* Note: this file is meant to be run in the browser, not in Node.js. WebdriverIO
* injects some basic polyfills for Node.js to make the following possible.
*/
import path from 'node:path';

/**
* A namespace for custom type definitions used in a portion of the testing suite
*/
export declare namespace SomeTypes {
type Number = number;
type String = string;
}

export async function setupIFrameTest(htmlFile: string): Promise<HTMLElement> {
if (document.querySelector('iframe')) {
document.body.removeChild(document.querySelector('iframe'));
}

const htmlFilePath = path.resolve(
path.dirname(globalThis.__wdioSpec__),
'..',
htmlFile.slice(htmlFile.startsWith('/') ? 1 : 0),
);
const iframe = document.createElement('iframe');

/**
* Note: prefixes the absolute path to the html file with `/@fs` is a ViteJS (https://vitejs.dev/)
* feature which allows to serve static content from files this way
*/
iframe.src = `/@fs${htmlFilePath}`;
iframe.width = '600px';
iframe.height = '600px';
document.body.appendChild(iframe);

/**
* wait for the iframe to load
*/
await new Promise((resolve) => (iframe.onload = resolve));
return iframe.contentDocument.body;
}

0 comments on commit 3cfbb8d

Please sign in to comment.