From 532557e01e9dd6782acddb27982e038089b314f2 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 21 May 2024 09:11:29 -0700 Subject: [PATCH 1/5] feat(runtime): support declarative shadow DOM feat(runtime): enhance renderToString to support serializeShadowRootAsDeclarativeShadowRoot flag make esm hydrate script make test work add unit test fix prettier wip minor tweaks apply more changes from #5787 get unit tests working prettier remove import fix test eslint fix use dynamic import minor e2e fixes prettier fix cspell adjust tests prettier allow to run headless make streaming work fix tests prettier remove obsolete file this should fix pre-render test prettier finally get it right prettier --- cspell-wordlist.txt | 1 + src/client/client-window.ts | 6 +- .../generate-hydrate-app.ts | 34 ++- .../write-hydrate-outputs.ts | 19 +- .../prerender/prerender-template-html.ts | 20 +- src/compiler/prerender/prerender-worker.ts | 16 +- src/declarations/stencil-private.ts | 1 + src/declarations/stencil-public-compiler.ts | 13 + src/hydrate/platform/h-async.ts | 12 +- src/hydrate/platform/hydrate-app.ts | 6 +- src/hydrate/platform/index.ts | 3 +- src/hydrate/platform/proxy-host-element.ts | 48 ++- src/hydrate/runner/index.ts | 2 +- src/hydrate/runner/render.ts | 284 +++++++++--------- src/mock-doc/node.ts | 14 + src/mock-doc/serialize-node.ts | 259 +++++++++------- src/mock-doc/test/element.spec.ts | 2 +- src/mock-doc/test/html-parse.spec.ts | 4 +- src/mock-doc/test/serialize-node.spec.ts | 14 +- src/mock-doc/window.ts | 69 +++-- src/runtime/bootstrap-custom-element.ts | 23 +- src/runtime/bootstrap-lazy.ts | 25 +- .../test/hydrate-shadow-in-shadow.spec.tsx | 12 +- src/runtime/vdom/test/patch-svg.spec.ts | 2 +- src/runtime/vdom/test/patch.spec.ts | 2 +- .../test/{to-vnode.spec.ts => util.spec.ts} | 2 +- .../vdom/{test/to-vnode.ts => util.ts} | 18 +- src/runtime/vdom/vdom-render.ts | 4 +- src/sys/node/node-sys.ts | 13 +- src/utils/helpers.ts | 2 - src/utils/test/helpers.spec.ts | 32 +- test/end-to-end/src/car-detail/car-detail.tsx | 3 +- test/end-to-end/src/car-list/car-list.e2e.ts | 1 + test/end-to-end/src/car-list/car-list.tsx | 3 +- test/end-to-end/src/components.d.ts | 88 ++++++ .../__snapshots__/test.e2e.ts.snap | 11 + .../another-car-detail.css | 3 + .../another-car-detail.tsx | 24 ++ .../another-car-list.css | 24 ++ .../another-car-list.tsx | 46 +++ .../src/declarative-shadow-dom/cmp-dsd.tsx | 17 ++ .../src/declarative-shadow-dom/readme.md | 10 + .../server-vs-client.tsx | 12 + .../src/declarative-shadow-dom/test.e2e.ts | 196 ++++++++++++ .../src/prerender-cmp/prerender-cmp.tsx | 1 + test/end-to-end/stencil.config.ts | 9 +- test/end-to-end/tsconfig.json | 1 + test/wdio/declarative-shadow-dom/cmp.test.tsx | 17 ++ test/wdio/declarative-shadow-dom/cmp.tsx | 12 + test/wdio/prerender-test/cmp.test.tsx | 5 +- test/wdio/slot-reorder/cmp.test.tsx | 10 +- test/wdio/stencil.config.ts | 4 + test/wdio/test-prerender/prerender.js | 4 +- test/wdio/util.ts | 8 +- 54 files changed, 1028 insertions(+), 443 deletions(-) rename src/runtime/vdom/test/{to-vnode.spec.ts => util.spec.ts} (97%) rename src/runtime/vdom/{test/to-vnode.ts => util.ts} (54%) create mode 100644 test/end-to-end/src/declarative-shadow-dom/__snapshots__/test.e2e.ts.snap create mode 100644 test/end-to-end/src/declarative-shadow-dom/another-car-detail.css create mode 100644 test/end-to-end/src/declarative-shadow-dom/another-car-detail.tsx create mode 100644 test/end-to-end/src/declarative-shadow-dom/another-car-list.css create mode 100644 test/end-to-end/src/declarative-shadow-dom/another-car-list.tsx create mode 100644 test/end-to-end/src/declarative-shadow-dom/cmp-dsd.tsx create mode 100644 test/end-to-end/src/declarative-shadow-dom/readme.md create mode 100644 test/end-to-end/src/declarative-shadow-dom/server-vs-client.tsx create mode 100644 test/end-to-end/src/declarative-shadow-dom/test.e2e.ts create mode 100644 test/wdio/declarative-shadow-dom/cmp.test.tsx create mode 100644 test/wdio/declarative-shadow-dom/cmp.tsx diff --git a/cspell-wordlist.txt b/cspell-wordlist.txt index 7d3216e13a3..018cf581896 100644 --- a/cspell-wordlist.txt +++ b/cspell-wordlist.txt @@ -106,6 +106,7 @@ runtimes searchbar shadowcsshost shadowcsshostcontext +shadowroot sourcemaps specfile stenciljs diff --git a/src/client/client-window.ts b/src/client/client-window.ts index 153bed5469a..4337a10419f 100644 --- a/src/client/client-window.ts +++ b/src/client/client-window.ts @@ -28,11 +28,7 @@ export const setPlatformHelpers = (helpers: { Object.assign(plt, helpers); }; -export const supportsShadow = - // TODO(STENCIL-854): Remove code related to legacy shadowDomShim field - BUILD.shadowDomShim && BUILD.shadowDom - ? /*@__PURE__*/ (() => (doc.head.attachShadow + '').indexOf('[native') > -1)() - : true; +export const supportsShadow = BUILD.shadowDom; export const supportsListenerOptions = /*@__PURE__*/ (() => { let supportsListenerOptions = false; diff --git a/src/compiler/output-targets/dist-hydrate-script/generate-hydrate-app.ts b/src/compiler/output-targets/dist-hydrate-script/generate-hydrate-app.ts index 73b4c5a1f7a..27f39233d58 100644 --- a/src/compiler/output-targets/dist-hydrate-script/generate-hydrate-app.ts +++ b/src/compiler/output-targets/dist-hydrate-script/generate-hydrate-app.ts @@ -1,9 +1,9 @@ +import type * as d from '@stencil/core/declarations'; import { catchError, createOnWarnFn, generatePreamble, join, loadRollupDiagnostics } from '@utils'; import MagicString from 'magic-string'; import { RollupOptions } from 'rollup'; -import { rollup } from 'rollup'; +import { rollup, type RollupBuild } from 'rollup'; -import type * as d from '../../../declarations'; import { STENCIL_HYDRATE_FACTORY_ID, STENCIL_INTERNAL_HYDRATE_ID, @@ -14,6 +14,24 @@ import { HYDRATE_FACTORY_INTRO, HYDRATE_FACTORY_OUTRO } from './hydrate-factory- import { updateToHydrateComponents } from './update-to-hydrate-components'; import { writeHydrateOutputs } from './write-hydrate-outputs'; +const buildHydrateAppFor = async ( + format: 'esm' | 'cjs', + rollupBuild: RollupBuild, + config: d.ValidatedConfig, + compilerCtx: d.CompilerCtx, + buildCtx: d.BuildCtx, + outputTargets: d.OutputTargetHydrate[], +) => { + const file = format === 'esm' ? 'index.mjs' : 'index.js'; + const rollupOutput = await rollupBuild.generate({ + banner: generatePreamble(config), + format, + file, + }); + + await writeHydrateOutputs(config, compilerCtx, buildCtx, outputTargets, rollupOutput); +}; + /** * Generate and build the hydrate app and then write it to disk * @@ -35,6 +53,7 @@ export const generateHydrateApp = async ( const rollupOptions: RollupOptions = { ...config.rollupConfig.inputOptions, + external: ['stream'], input, inlineDynamicImports: true, @@ -63,13 +82,10 @@ export const generateHydrateApp = async ( }; const rollupAppBuild = await rollup(rollupOptions); - const rollupOutput = await rollupAppBuild.generate({ - banner: generatePreamble(config), - format: 'cjs', - file: 'index.js', - }); - - await writeHydrateOutputs(config, compilerCtx, buildCtx, outputTargets, rollupOutput); + await Promise.all([ + buildHydrateAppFor('cjs', rollupAppBuild, config, compilerCtx, buildCtx, outputTargets), + buildHydrateAppFor('esm', rollupAppBuild, config, compilerCtx, buildCtx, outputTargets), + ]); } catch (e: any) { if (!buildCtx.hasError) { // TODO(STENCIL-353): Implement a type guard that balances using our own copy of Rollup types (which are diff --git a/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts b/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts index 33aef31c8e7..e0e5c75b87d 100644 --- a/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts +++ b/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts @@ -31,12 +31,14 @@ const writeHydrateOutput = async ( const hydrateAppDirPath = outputTarget.dir; const hydrateCoreIndexPath = join(hydrateAppDirPath, 'index.js'); + const hydrateCoreIndexPathESM = join(hydrateAppDirPath, 'index.mjs'); const hydrateCoreIndexDtsFilePath = join(hydrateAppDirPath, 'index.d.ts'); const pkgJsonPath = join(hydrateAppDirPath, 'package.json'); const pkgJsonCode = getHydratePackageJson( config, hydrateCoreIndexPath, + hydrateCoreIndexPathESM, hydrateCoreIndexDtsFilePath, hydratePackageName, ); @@ -62,28 +64,37 @@ const writeHydrateOutput = async ( const getHydratePackageJson = ( config: d.ValidatedConfig, - hydrateAppFilePath: string, + hydrateAppFilePathCJS: string, + hydrateAppFilePathESM: string, hydrateDtsFilePath: string, hydratePackageName: string, ) => { const pkg: d.PackageJsonData = { name: hydratePackageName, description: `${config.namespace} component hydration app.`, - main: basename(hydrateAppFilePath), + main: basename(hydrateAppFilePathCJS), types: basename(hydrateDtsFilePath), + exports: { + '.': { + require: `./${basename(hydrateAppFilePathCJS)}`, + import: `./${basename(hydrateAppFilePathESM)}`, + }, + }, }; return JSON.stringify(pkg, null, 2); }; const getHydratePackageName = async (config: d.ValidatedConfig, compilerCtx: d.CompilerCtx) => { + const directoryName = basename(config.rootDir); try { const rootPkgFilePath = join(config.rootDir, 'package.json'); const pkgStr = await compilerCtx.fs.readFile(rootPkgFilePath); const pkgData = JSON.parse(pkgStr) as d.PackageJsonData; - return `${pkgData.name}/hydrate`; + const scope = pkgData.name || directoryName; + return `${scope}/hydrate`; } catch (e) {} - return `${config.fsNamespace}/hydrate`; + return `${config.fsNamespace || directoryName}/hydrate`; }; const copyHydrateRunnerDts = async ( diff --git a/src/compiler/prerender/prerender-template-html.ts b/src/compiler/prerender/prerender-template-html.ts index 7c27466dd20..b0bd526f55a 100644 --- a/src/compiler/prerender/prerender-template-html.ts +++ b/src/compiler/prerender/prerender-template-html.ts @@ -1,5 +1,5 @@ import { createDocument, serializeNodeToHtml } from '@stencil/core/mock-doc'; -import { catchError, isFunction, isPromise, isString } from '@utils'; +import { catchError, isFunction, isString } from '@utils'; import type * as d from '../../declarations'; import { @@ -28,11 +28,7 @@ export const generateTemplateHtml = async ( let templateHtml: string; if (isFunction(prerenderConfig.loadTemplate)) { const loadTemplateResult = prerenderConfig.loadTemplate(srcIndexHtmlPath); - if (isPromise(loadTemplateResult)) { - templateHtml = await loadTemplateResult; - } else { - templateHtml = loadTemplateResult; - } + templateHtml = await loadTemplateResult; } else { templateHtml = await config.sys.readFile(srcIndexHtmlPath); } @@ -83,22 +79,14 @@ export const generateTemplateHtml = async ( if (isFunction(prerenderConfig.beforeSerializeTemplate)) { const beforeSerializeResults = prerenderConfig.beforeSerializeTemplate(doc); - if (isPromise(beforeSerializeResults)) { - doc = await beforeSerializeResults; - } else { - doc = beforeSerializeResults; - } + doc = await beforeSerializeResults; } let html = serializeNodeToHtml(doc); if (isFunction(prerenderConfig.afterSerializeTemplate)) { const afterSerializeResults = prerenderConfig.afterSerializeTemplate(html); - if (isPromise(afterSerializeResults)) { - html = await afterSerializeResults; - } else { - html = afterSerializeResults; - } + html = await afterSerializeResults; } return { diff --git a/src/compiler/prerender/prerender-worker.ts b/src/compiler/prerender/prerender-worker.ts index c884a52c163..f1dc300b1f5 100644 --- a/src/compiler/prerender/prerender-worker.ts +++ b/src/compiler/prerender/prerender-worker.ts @@ -1,4 +1,4 @@ -import { catchError, isFunction, isPromise, isRootPath, join, normalizePath } from '@utils'; +import { catchError, isFunction, isRootPath, join, normalizePath } from '@utils'; import { dirname } from 'path'; import type * as d from '../../declarations'; @@ -70,10 +70,7 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d if (typeof prerenderConfig.beforeHydrate === 'function') { try { - const rtn = prerenderConfig.beforeHydrate(doc, url); - if (isPromise(rtn)) { - await rtn; - } + await prerenderConfig.beforeHydrate(doc, url); } catch (e: any) { catchError(results.diagnostics, e); } @@ -81,7 +78,7 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d // parse the html to dom nodes, hydrate the components, then // serialize the hydrated dom nodes back to into html - const hydrateResults = (await hydrateApp.hydrateDocument(doc, hydrateOpts)) as d.HydrateResults; + const hydrateResults: d.HydrateResults = await hydrateApp.hydrateDocument(doc, hydrateOpts); results.diagnostics.push(...hydrateResults.diagnostics); if (typeof prerenderConfig.filePath === 'function') { @@ -148,10 +145,7 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d if (typeof prerenderConfig.afterHydrate === 'function') { try { - const rtn = prerenderConfig.afterHydrate(doc, url, results); - if (isPromise(rtn)) { - await rtn; - } + await prerenderConfig.afterHydrate(doc, url, results); } catch (e: any) { catchError(results.diagnostics, e); } @@ -164,7 +158,7 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d return results; } - const html = hydrateApp.serializeDocumentToString(doc, hydrateOpts); + const html = await hydrateApp.serializeDocumentToString(doc, hydrateOpts); prerenderEnsureDir(sys, prerenderCtx, results.filePath); diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index faa46e0d023..3ec06d5e894 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1954,6 +1954,7 @@ export interface PackageJsonData { name?: string; version?: string; main?: string; + exports?: { [key: string]: string | { [key: string]: string } }; description?: string; bin?: { [key: string]: string }; browser?: string; diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index b6a75286135..709ca74cf9b 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -933,6 +933,19 @@ export interface SerializeDocumentOptions extends HydrateDocumentOptions { * Remove HTML comments. Defaults to `true`. */ removeHtmlComments?: boolean; + /** + * If set to `false` Stencil will ignore the fact that a component has a `shadow: true` + * flag and serializes it as a scoped component. If set to `true` the component will + * be rendered within a Declarative Shadow DOM. + * @default false + */ + serializeShadowRoot?: boolean; + /** + * The `fullDocument` flag determines the format of the rendered output. Set it to true to + * generate a complete HTML document, or false to render only the component. + * @default true + */ + fullDocument?: boolean; } export interface HydrateFactoryOptions extends SerializeDocumentOptions { diff --git a/src/hydrate/platform/h-async.ts b/src/hydrate/platform/h-async.ts index 928c96a9468..a60ca5c9560 100644 --- a/src/hydrate/platform/h-async.ts +++ b/src/hydrate/platform/h-async.ts @@ -1,6 +1,5 @@ import { consoleDevError } from '@platform'; import { h } from '@runtime'; -import { isPromise } from '@utils'; import type * as d from '../../declarations'; @@ -8,9 +7,9 @@ export const hAsync = (nodeName: any, vnodeData: any, ...children: d.ChildType[] if (Array.isArray(children) && children.length > 0) { // only return a promise if we have to const flatChildren = children.flat(Infinity); - if (flatChildren.some(isPromise)) { - // has children and at least one of them is async - // wait on all of them to be resolved + // has children and at least one of them is async + // wait on all of them to be resolved + if (flatChildren.some((child) => child instanceof Promise)) { return Promise.all(flatChildren) .then((resolvedChildren) => { return h(nodeName, vnodeData, ...resolvedChildren); @@ -20,9 +19,8 @@ export const hAsync = (nodeName: any, vnodeData: any, ...children: d.ChildType[] return h(nodeName, vnodeData); }); } - - // no async children, return sync - return h(nodeName, vnodeData, ...children); + // no async children, just return sync + return h(nodeName, vnodeData, ...flatChildren); } // no children, return sync diff --git a/src/hydrate/platform/hydrate-app.ts b/src/hydrate/platform/hydrate-app.ts index 92b6e8bb543..c0f544e6048 100644 --- a/src/hydrate/platform/hydrate-app.ts +++ b/src/hydrate/platform/hydrate-app.ts @@ -28,7 +28,7 @@ export function hydrateApp( let ranCompleted = false; function hydratedComplete() { - global.clearTimeout(tmrId); + globalThis.clearTimeout(tmrId); createdElements.clear(); connectedElements.clear(); @@ -91,7 +91,7 @@ export function hydrateApp( registerHost(elm, Cstr.cmpMeta); // proxy the host element with the component's metadata - proxyHostElement(elm, Cstr.cmpMeta); + proxyHostElement(elm, Cstr.cmpMeta, opts); } } } @@ -148,7 +148,7 @@ export function hydrateApp( } as (typeof window)['document']['createElementNS']; // ensure we use NodeJS's native setTimeout, not the mocked hydrate app scoped one - tmrId = global.setTimeout(timeoutExceeded, opts.timeout); + tmrId = globalThis.setTimeout(timeoutExceeded, opts.timeout); plt.$resourcesUrl$ = new URL(opts.resourcesUrl || './', doc.baseURI).href; diff --git a/src/hydrate/platform/index.ts b/src/hydrate/platform/index.ts index 386d0e93ef9..536fa8bcc72 100644 --- a/src/hydrate/platform/index.ts +++ b/src/hydrate/platform/index.ts @@ -1,3 +1,4 @@ +import { BUILD } from '@app-data'; import { addHostEventListeners } from '@runtime'; import type * as d from '../../declarations'; @@ -120,7 +121,7 @@ export const setPlatformHelpers = (helpers: { Object.assign(plt, helpers); }; -export const supportsShadow = false; +export const supportsShadow = BUILD.shadowDom; export const supportsListenerOptions = false; diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts index 40197dd98a0..16ee6e5485c 100644 --- a/src/hydrate/platform/proxy-host-element.ts +++ b/src/hydrate/platform/proxy-host-element.ts @@ -1,18 +1,40 @@ +import { BUILD } from '@app-data'; import { consoleError, getHostRef } from '@platform'; import { getValue, parsePropertyValue, setValue } from '@runtime'; import { CMP_FLAGS, MEMBER_FLAGS } from '@utils'; import type * as d from '../../declarations'; -export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntimeMeta): void { +export function proxyHostElement( + elm: d.HostElement, + cmpMeta: d.ComponentRuntimeMeta, + opts: d.HydrateFactoryOptions, +): void { if (typeof elm.componentOnReady !== 'function') { elm.componentOnReady = componentOnReady; } if (typeof elm.forceUpdate !== 'function') { elm.forceUpdate = forceUpdate; } - if (cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation) { - (elm as any).shadowRoot = elm; + + /** + * Only attach shadow root if there isn't one already + */ + if (!elm.shadowRoot && !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDomEncapsulation)) { + if (BUILD.shadowDelegatesFocus) { + elm.attachShadow({ + mode: 'open', + delegatesFocus: !!(cmpMeta.$flags$ & CMP_FLAGS.shadowDelegatesFocus), + }); + } else if (opts.serializeShadowRoot) { + elm.attachShadow({ mode: 'open' }); + } else { + /** + * For hydration users may want to render the shadow component as scoped + * component, so we need to assign the element as shadowRoot. + */ + (elm as any).shadowRoot = elm; + } } if (cmpMeta.$members$ != null) { @@ -25,7 +47,25 @@ export function proxyHostElement(elm: d.HostElement, cmpMeta: d.ComponentRuntime if (memberFlags & MEMBER_FLAGS.Prop) { const attributeName = m[1] || memberName; - const attrValue = elm.getAttribute(attributeName); + let attrValue = elm.getAttribute(attributeName); + + /** + * allow hydrate parameters that contain a simple object, e.g. + * ```ts + * import { renderToString } from 'component-library/hydrate'; + * await renderToString(``); + * ``` + */ + if ( + (attrValue?.startsWith('{') && attrValue.endsWith('}')) || + (attrValue?.startsWith('[') && attrValue.endsWith(']')) + ) { + try { + attrValue = JSON.parse(attrValue); + } catch (e) { + /* ignore */ + } + } if (attrValue != null) { const parsedAttrValue = parsePropertyValue(attrValue, memberFlags); diff --git a/src/hydrate/runner/index.ts b/src/hydrate/runner/index.ts index 27b73fa0d44..ca11c860a5d 100644 --- a/src/hydrate/runner/index.ts +++ b/src/hydrate/runner/index.ts @@ -1,2 +1,2 @@ export { createWindowFromHtml } from './create-window'; -export { hydrateDocument, renderToString, serializeDocumentToString } from './render'; +export { hydrateDocument, renderToString, serializeDocumentToString, streamToString } from './render'; diff --git a/src/hydrate/runner/render.ts b/src/hydrate/runner/render.ts index 74e9576e74a..6ac7b0ae97a 100644 --- a/src/hydrate/runner/render.ts +++ b/src/hydrate/runner/render.ts @@ -1,6 +1,8 @@ +import { Readable } from 'node:stream'; + import { hydrateFactory } from '@hydrate-factory'; import { MockWindow, serializeNodeToHtml } from '@stencil/core/mock-doc'; -import { hasError, isPromise } from '@utils'; +import { hasError } from '@utils'; import { updateCanonicalLink } from '../../compiler/html/canonical-link'; import { relocateMetaCharset } from '../../compiler/html/relocate-meta-charset'; @@ -16,161 +18,161 @@ import { patchDomImplementation } from './patch-dom-implementation'; import { generateHydrateResults, normalizeHydrateOptions, renderBuildError, renderCatchError } from './render-utils'; import { initializeWindow } from './window-initialize'; -export function renderToString(html: string | any, options?: SerializeDocumentOptions) { +const NOOP = () => {}; + +export function streamToString(html: string | any, option?: SerializeDocumentOptions) { + return renderToString(html, option, true); +} + +export function renderToString( + html: string | any, + options: SerializeDocumentOptions | undefined, + asStream: true, +): Readable; +export function renderToString(html: string | any, options?: SerializeDocumentOptions): Promise; +export function renderToString( + html: string | any, + options?: SerializeDocumentOptions, + asStream?: true, +): Promise | Readable { const opts = normalizeHydrateOptions(options); + /** + * Makes the rendered DOM not being rendered to a string. + */ opts.serializeToHtml = true; + /** + * Set the flag whether or not we like to render into a declarative shadow root. + */ + opts.fullDocument = typeof opts.fullDocument === 'boolean' ? opts.fullDocument : true; + /** + * Defines whether we render the shadow root as a declarative shadow root or as scoped shadow root. + */ + opts.serializeShadowRoot = Boolean(opts.serializeShadowRoot); + /** + * Make sure we wait for components to be hydrated. + */ + opts.constrainTimeouts = false; - return new Promise((resolve) => { - let win: Window & typeof globalThis; - const results = generateHydrateResults(opts); + return hydrateDocument(html, opts, asStream); +} - if (hasError(results.diagnostics)) { - resolve(results); - } else if (typeof html === 'string') { - try { - opts.destroyWindow = true; - opts.destroyDocument = true; - win = new MockWindow(html) as any; - render(win, opts, results, resolve); - } catch (e) { - if (win && win.close) { - win.close(); - } - win = null; - renderCatchError(results, e); - resolve(results); +export function hydrateDocument( + doc: any | string, + options: HydrateDocumentOptions | undefined, + asStream: true, +): Readable; +export function hydrateDocument(doc: any | string, options?: HydrateDocumentOptions): Promise; +export function hydrateDocument( + doc: any | string, + options?: HydrateDocumentOptions, + asStream?: true, +): Promise | Readable { + const opts = normalizeHydrateOptions(options); + + let win: Window & typeof globalThis; + const results = generateHydrateResults(opts); + + if (hasError(results.diagnostics)) { + return Promise.resolve(results); + } + + if (typeof doc === 'string') { + try { + opts.destroyWindow = true; + opts.destroyDocument = true; + win = new MockWindow(doc) as any; + + if (!asStream) { + return render(win, opts, results).then(() => results); } - } else if (isValidDocument(html)) { - try { - opts.destroyDocument = false; - win = patchDomImplementation(html, opts); - render(win, opts, results, resolve); - } catch (e) { - if (win && win.close) { - win.close(); - } - win = null; - renderCatchError(results, e); - resolve(results); + + return renderStream(win, opts, results); + } catch (e) { + if (win && win.close) { + win.close(); } - } else { - renderBuildError(results, `Invalid html or document. Must be either a valid "html" string, or DOM "document".`); - resolve(results); + win = null; + renderCatchError(results, e); + return Promise.resolve(results); } - }); -} - -export function hydrateDocument(doc: any | string, options?: HydrateDocumentOptions) { - const opts = normalizeHydrateOptions(options); - opts.serializeToHtml = false; + } - return new Promise((resolve) => { - let win: Window & typeof globalThis; - const results = generateHydrateResults(opts); + if (isValidDocument(doc)) { + try { + opts.destroyDocument = false; + win = patchDomImplementation(doc, opts); - if (hasError(results.diagnostics)) { - resolve(results); - } else if (typeof doc === 'string') { - try { - opts.destroyWindow = true; - opts.destroyDocument = true; - win = new MockWindow(doc) as any; - render(win, opts, results, resolve); - } catch (e) { - if (win && win.close) { - win.close(); - } - win = null; - renderCatchError(results, e); - resolve(results); + if (!asStream) { + return render(win, opts, results).then(() => results); } - } else if (isValidDocument(doc)) { - try { - opts.destroyDocument = false; - win = patchDomImplementation(doc, opts); - render(win, opts, results, resolve); - } catch (e) { - if (win && win.close) { - win.close(); - } - win = null; - renderCatchError(results, e); - resolve(results); + + return renderStream(win, opts, results); + } catch (e) { + if (win && win.close) { + win.close(); } - } else { - renderBuildError(results, `Invalid html or document. Must be either a valid "html" string, or DOM "document".`); - resolve(results); + win = null; + renderCatchError(results, e); + return Promise.resolve(results); } - }); + } + + renderBuildError(results, `Invalid html or document. Must be either a valid "html" string, or DOM "document".`); + return Promise.resolve(results); } -function render( - win: Window & typeof globalThis, - opts: HydrateFactoryOptions, - results: HydrateResults, - resolve: (results: HydrateResults) => void, -) { - if (!(process as any).__stencilErrors) { +async function render(win: Window & typeof globalThis, opts: HydrateFactoryOptions, results: HydrateResults) { + if ('process' in globalThis && typeof process.on === 'function' && !(process as any).__stencilErrors) { (process as any).__stencilErrors = true; - process.on('unhandledRejection', (e) => { console.log('unhandledRejection', e); }); } initializeWindow(win, win.document, opts, results); + const beforeHydrateFn = typeof opts.beforeHydrate === 'function' ? opts.beforeHydrate(win.document) : NOOP; + try { + await Promise.resolve(beforeHydrateFn(win.document)); + return new Promise((resolve) => hydrateFactory(win, opts, results, afterHydrate, resolve)); + } catch (e) { + renderCatchError(results, e); + return finalizeHydrate(win, win.document, opts, results); + } +} - if (typeof opts.beforeHydrate === 'function') { - try { - const rtn = opts.beforeHydrate(win.document); - if (isPromise(rtn)) { - rtn.then(() => { - hydrateFactory(win, opts, results, afterHydrate, resolve); - }); - } else { - hydrateFactory(win, opts, results, afterHydrate, resolve); - } - } catch (e) { - renderCatchError(results, e); - finalizeHydrate(win, win.document, opts, results, resolve); - } - } else { - hydrateFactory(win, opts, results, afterHydrate, resolve); +/** + * Wrapper around `render` method to enable streaming by returning a Readable instead of a promise. + * @param win MockDoc window object + * @param opts serialization options + * @param results render result object + * @returns a Readable that can be passed into a response + */ +function renderStream(win: Window & typeof globalThis, opts: HydrateFactoryOptions, results: HydrateResults) { + async function* processRender() { + const renderResult = await render(win, opts, results); + yield renderResult.html; } + + return Readable.from(processRender()); } -function afterHydrate( +async function afterHydrate( win: Window, opts: HydrateFactoryOptions, results: HydrateResults, resolve: (results: HydrateResults) => void, ) { - if (typeof opts.afterHydrate === 'function') { - try { - const rtn = opts.afterHydrate(win.document); - if (isPromise(rtn)) { - rtn.then(() => { - finalizeHydrate(win, win.document, opts, results, resolve); - }); - } else { - finalizeHydrate(win, win.document, opts, results, resolve); - } - } catch (e) { - renderCatchError(results, e); - finalizeHydrate(win, win.document, opts, results, resolve); - } - } else { - finalizeHydrate(win, win.document, opts, results, resolve); + const afterHydrateFn = typeof opts.afterHydrate === 'function' ? opts.afterHydrate(win.document) : NOOP; + try { + await Promise.resolve(afterHydrateFn(win.document)); + return resolve(finalizeHydrate(win, win.document, opts, results)); + } catch (e) { + renderCatchError(results, e); + return resolve(finalizeHydrate(win, win.document, opts, results)); } } -function finalizeHydrate( - win: Window, - doc: Document, - opts: HydrateFactoryOptions, - results: HydrateResults, - resolve: (results: HydrateResults) => void, -) { +function finalizeHydrate(win: Window, doc: Document, opts: HydrateFactoryOptions, results: HydrateResults) { try { inspectElement(results, doc.documentElement, 0); @@ -231,25 +233,30 @@ function finalizeHydrate( renderCatchError(results, e); } - if (opts.destroyWindow) { - try { - if (!opts.destroyDocument) { - (win as any).document = null; - (doc as any).defaultView = null; - } + destroyWindow(win, doc, opts, results); + return results; +} - if (win.close) { - win.close(); - } - } catch (e) { - renderCatchError(results, e); - } +function destroyWindow(win: Window, doc: Document, opts: HydrateFactoryOptions, results: HydrateResults) { + if (!opts.destroyWindow) { + return; } - resolve(results); + try { + if (!opts.destroyDocument) { + (win as any).document = null; + (doc as any).defaultView = null; + } + + if (win.close) { + win.close(); + } + } catch (e) { + renderCatchError(results, e); + } } -export function serializeDocumentToString(doc: any, opts: HydrateFactoryOptions) { +export function serializeDocumentToString(doc: Document, opts: HydrateFactoryOptions) { return serializeNodeToHtml(doc, { approximateLineWidth: opts.approximateLineWidth, outerHtml: false, @@ -258,7 +265,8 @@ export function serializeDocumentToString(doc: any, opts: HydrateFactoryOptions) removeBooleanAttributeQuotes: opts.removeBooleanAttributeQuotes, removeEmptyAttributes: opts.removeEmptyAttributes, removeHtmlComments: opts.removeHtmlComments, - serializeShadowRoot: false, + serializeShadowRoot: opts.serializeShadowRoot, + fullDocument: opts.fullDocument, }); } diff --git a/src/mock-doc/node.ts b/src/mock-doc/node.ts index ba1e1797795..da8f2ad8143 100644 --- a/src/mock-doc/node.ts +++ b/src/mock-doc/node.ts @@ -287,11 +287,25 @@ Testing components with ElementInternals is fully supported in e2e tests.`, return this.__shadowRoot || null; } + /** + * Set shadow root for element + * @param shadowRoot - ShadowRoot to set + */ set shadowRoot(shadowRoot: any) { if (shadowRoot != null) { shadowRoot.host = this; this.__shadowRoot = shadowRoot; } else { + /** + * There are use cases where we want to render a component with `shadow: true` as + * a scoped component. In this case, we don't want to have a shadow root attached + * to the element. This is why we need to be able to remove the shadow root. + * + * For example: + * calling `renderToString('', { + * serializeShadowRoot: false + * })` + */ delete this.__shadowRoot; } } diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index ee75ed901cb..120266a54f9 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -1,16 +1,52 @@ import { CONTENT_REF_ID, ORG_LOCATION_ID, SLOT_NODE_ID, TEXT_NODE_ID, XLINK_NS } from '../runtime/runtime-constants'; import { cloneAttributes } from './attribute'; import { NODE_TYPES } from './constants'; -import { MockNode } from './node'; +import { type MockDocument } from './document'; +import { type MockNode } from './node'; /** - * Serialize a node (either a DOM node or a mock-doc node) to an HTML string + * Set default values for serialization options. + * @param opts options to control serialization behavior + * @returns normalized serialization options + */ +function normalizeSerializationOptions(opts: Partial = {}) { + return { + ...opts, + outerHtml: typeof opts.outerHtml !== 'boolean' ? false : opts.outerHtml, + ...(opts.prettyHtml + ? { + indentSpaces: typeof opts.indentSpaces !== 'number' ? 2 : opts.indentSpaces, + newLines: typeof opts.newLines !== 'boolean' ? true : opts.newLines, + } + : { + prettyHtml: false, + indentSpaces: typeof opts.indentSpaces !== 'number' ? 0 : opts.indentSpaces, + newLines: typeof opts.newLines !== 'boolean' ? false : opts.newLines, + }), + approximateLineWidth: typeof opts.approximateLineWidth !== 'number' ? -1 : opts.approximateLineWidth, + removeEmptyAttributes: typeof opts.removeEmptyAttributes !== 'boolean' ? true : opts.removeEmptyAttributes, + removeAttributeQuotes: typeof opts.removeAttributeQuotes !== 'boolean' ? false : opts.removeAttributeQuotes, + removeBooleanAttributeQuotes: + typeof opts.removeBooleanAttributeQuotes !== 'boolean' ? false : opts.removeBooleanAttributeQuotes, + removeHtmlComments: typeof opts.removeHtmlComments !== 'boolean' ? false : opts.removeHtmlComments, + serializeShadowRoot: typeof opts.serializeShadowRoot !== 'boolean' ? false : opts.serializeShadowRoot, + fullDocument: typeof opts.fullDocument !== 'boolean' ? true : opts.fullDocument, + } as const; +} + +/** + * Serialize a node (either a DOM node or a mock-doc node) to an HTML string. + * This operation is similar to `outerHTML` but allows for more control over the + * serialization process. It is fully synchronous meaning that it will not + * wait for a component to be fully rendered before serializing it. Use `streamToHtml` + * for a streaming version of this function. * * @param elm the node to serialize - * @param opts options to control serialization behavior + * @param serializationOptions options to control serialization behavior * @returns an html string */ -export function serializeNodeToHtml(elm: Node | MockNode, opts: SerializeNodeToHtmlOptions = {}) { +export function serializeNodeToHtml(elm: Node | MockNode, serializationOptions: SerializeNodeToHtmlOptions = {}) { + const opts = normalizeSerializationOptions(serializationOptions); const output: SerializeOutput = { currentLineWidth: 0, indent: 0, @@ -18,71 +54,44 @@ export function serializeNodeToHtml(elm: Node | MockNode, opts: SerializeNodeToH text: [], }; - if (opts.prettyHtml) { - if (typeof opts.indentSpaces !== 'number') { - opts.indentSpaces = 2; - } - - if (typeof opts.newLines !== 'boolean') { - opts.newLines = true; - } - opts.approximateLineWidth = -1; - } else { - opts.prettyHtml = false; - if (typeof opts.newLines !== 'boolean') { - opts.newLines = false; - } - if (typeof opts.indentSpaces !== 'number') { - opts.indentSpaces = 0; - } - } - - if (typeof opts.approximateLineWidth !== 'number') { - opts.approximateLineWidth = -1; - } - - if (typeof opts.removeEmptyAttributes !== 'boolean') { - opts.removeEmptyAttributes = true; - } - - if (typeof opts.removeAttributeQuotes !== 'boolean') { - opts.removeAttributeQuotes = false; - } - - if (typeof opts.removeBooleanAttributeQuotes !== 'boolean') { - opts.removeBooleanAttributeQuotes = false; - } - - if (typeof opts.removeHtmlComments !== 'boolean') { - opts.removeHtmlComments = false; - } - - if (typeof opts.serializeShadowRoot !== 'boolean') { - opts.serializeShadowRoot = false; + let renderedNode = ''; + const children = + !opts.fullDocument && (elm as MockDocument).body + ? Array.from((elm as MockDocument).body.childNodes) + : opts.outerHtml + ? [elm] + : Array.from(elm.childNodes as NodeList); + + for (let i = 0, ii = children.length; i < ii; i++) { + const child = children[i]; + const chunks = Array.from(streamToHtml(child, opts, output)); + renderedNode += chunks.join(''); } - if (opts.outerHtml) { - serializeToHtml(elm as Node, opts, output, false); - } else { - for (let i = 0, ii = elm.childNodes.length; i < ii; i++) { - serializeToHtml(elm.childNodes[i] as Node, opts, output, false); - } - } + return renderedNode.trim(); +} - if (output.text[0] === '\n') { - output.text.shift(); - } +const shadowRootTag = 'mock:shadow-root'; - if (output.text[output.text.length - 1] === '\n') { - output.text.pop(); - } - - return output.text.join(''); -} +/** + * Same as `serializeNodeToHtml` but returns a generator that yields the serialized + * HTML in chunks. This is useful for streaming the serialized HTML to the client + * as it is being generated. + * + * @param node the node to serialize + * @param opts options to control serialization behavior + * @param output keeps track of the current line width and indentation + * @returns a generator that yields the serialized HTML in chunks + */ +function* streamToHtml( + node: Node | MockNode, + opts: SerializeNodeToHtmlOptions, + output: Omit, +): Generator { + const isShadowRoot = node.nodeType === NODE_TYPES.DOCUMENT_FRAGMENT_NODE; -function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: SerializeOutput, isShadowRoot: boolean) { if (node.nodeType === NODE_TYPES.ELEMENT_NODE || isShadowRoot) { - const tagName = isShadowRoot ? 'mock:shadow-root' : getTagName(node as Element); + const tagName = isShadowRoot ? shadowRootTag : getTagName(node as Element); if (tagName === 'body') { output.isWithinBody = true; @@ -94,19 +103,31 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S const isWithinWhitespaceSensitiveNode = opts.newLines || (opts.indentSpaces ?? 0) > 0 ? isWithinWhitespaceSensitive(node) : false; if (opts.newLines && !isWithinWhitespaceSensitiveNode) { - output.text.push('\n'); + yield '\n'; output.currentLineWidth = 0; } if ((opts.indentSpaces ?? 0) > 0 && !isWithinWhitespaceSensitiveNode) { for (let i = 0; i < output.indent; i++) { - output.text.push(' '); + yield ' '; } output.currentLineWidth += output.indent; } - output.text.push('<' + tagName); - output.currentLineWidth += tagName.length + 1; + const tag = tagName === shadowRootTag ? 'template' : tagName; + + yield '<' + tag; + output.currentLineWidth += tag.length + 1; + + /** + * ToDo(https://github.com/ionic-team/stencil/issues/4111): the shadow root class is `#document-fragment` + * and has no mode attribute. We should consider adding a mode attribute. + */ + if (tag === 'template') { + const mode = ` shadowrootmode="open"`; + yield mode; + output.currentLineWidth += mode.length; + } const attrsLength = (node as HTMLElement).attributes.length; const attributes = @@ -135,27 +156,27 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S opts.approximateLineWidth > 0 && output.currentLineWidth > opts.approximateLineWidth ) { - output.text.push('\n' + attrName); + yield '\n' + attrName; output.currentLineWidth = 0; } else { - output.text.push(' ' + attrName); + yield ' ' + attrName; } } else if (attrNamespaceURI === 'http://www.w3.org/XML/1998/namespace') { - output.text.push(' xml:' + attrName); + yield ' xml:' + attrName; output.currentLineWidth += attrName.length + 5; } else if (attrNamespaceURI === 'http://www.w3.org/2000/xmlns/') { if (attrName !== 'xmlns') { - output.text.push(' xmlns:' + attrName); + yield ' xmlns:' + attrName; output.currentLineWidth += attrName.length + 7; } else { - output.text.push(' ' + attrName); + yield ' ' + attrName; output.currentLineWidth += attrName.length + 1; } } else if (attrNamespaceURI === XLINK_NS) { - output.text.push(' xlink:' + attrName); + yield ' xlink:' + attrName; output.currentLineWidth += attrName.length + 7; } else { - output.text.push(' ' + attrNamespaceURI + ':' + attrName); + yield ' ' + attrNamespaceURI + ':' + attrName; output.currentLineWidth += attrNamespaceURI.length + attrName.length + 2; } @@ -178,10 +199,10 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S } if (opts.removeAttributeQuotes && CAN_REMOVE_ATTR_QUOTES.test(attrValue)) { - output.text.push('=' + escapeString(attrValue, true)); + yield '=' + escapeString(attrValue, true); output.currentLineWidth += attrValue.length + 1; } else { - output.text.push('="' + escapeString(attrValue, true) + '"'); + yield '="' + escapeString(attrValue, true) + '"'; output.currentLineWidth += attrValue.length + 3; } } @@ -194,22 +215,24 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S opts.approximateLineWidth > 0 && output.currentLineWidth + cssText.length + 10 > opts.approximateLineWidth ) { - output.text.push(`\nstyle="${cssText}">`); + yield `\nstyle="${cssText}">`; output.currentLineWidth = 0; } else { - output.text.push(` style="${cssText}">`); + yield ` style="${cssText}">`; output.currentLineWidth += cssText.length + 10; } } else { - output.text.push('>'); + yield '>'; output.currentLineWidth += 1; } } if (EMPTY_ELEMENTS.has(tagName) === false) { - if (opts.serializeShadowRoot && (node as HTMLElement).shadowRoot != null) { + const shadowRoot = (node as HTMLElement).shadowRoot; + if (opts.serializeShadowRoot && shadowRoot != null) { output.indent = output.indent + (opts.indentSpaces ?? 0); - serializeToHtml((node as HTMLElement).shadowRoot!, opts, output, true); + + yield* streamToHtml(shadowRoot, opts, output); output.indent = output.indent - (opts.indentSpaces ?? 0); if ( @@ -219,17 +242,18 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S node.childNodes[0].nodeType === NODE_TYPES.TEXT_NODE && node.childNodes[0].nodeValue?.trim() === '')) ) { - output.text.push('\n'); + yield '\n'; output.currentLineWidth = 0; for (let i = 0; i < output.indent; i++) { - output.text.push(' '); + yield ' '; } output.currentLineWidth += output.indent; } } if (opts.excludeTagContent == null || opts.excludeTagContent.includes(tagName) === false) { + const tag = tagName === shadowRootTag ? 'template' : tagName; const childNodes = tagName === 'template' ? ((node as any as HTMLTemplateElement).content.childNodes as any) : node.childNodes; const childNodeLength = childNodes.length; @@ -250,19 +274,19 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S } for (let i = 0; i < childNodeLength; i++) { - serializeToHtml(childNodes[i], opts, output, false); + yield* streamToHtml(childNodes[i], opts, output); } if (ignoreTag === false) { if (opts.newLines && !isWithinWhitespaceSensitiveNode) { - output.text.push('\n'); + yield '\n'; output.currentLineWidth = 0; } if ((opts.indentSpaces ?? 0) > 0 && !isWithinWhitespaceSensitiveNode) { output.indent = output.indent - (opts.indentSpaces ?? 0); for (let i = 0; i < output.indent; i++) { - output.text.push(' '); + yield ' '; } output.currentLineWidth += output.indent; } @@ -271,14 +295,14 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S } if (ignoreTag === false) { - output.text.push(''); - output.currentLineWidth += tagName.length + 3; + yield ''; + output.currentLineWidth += tag.length + 3; } } } if ((opts.approximateLineWidth ?? 0) > 0 && STRUCTURE_ELEMENTS.has(tagName)) { - output.text.push('\n'); + yield '\n'; output.currentLineWidth = 0; } @@ -295,7 +319,7 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S if (isWithinWhitespaceSensitive(node)) { // whitespace matters within this element // just add the exact text we were given - output.text.push(textContent); + yield textContent; output.currentLineWidth += textContent.length; } else if ((opts.approximateLineWidth ?? 0) > 0 && !output.isWithinBody) { // do nothing if we're not in the and we're tracking line width @@ -313,11 +337,11 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S // good enough for a new line // for perf these are all just estimates // we don't care to ensure exact line lengths - output.text.push('\n'); + yield '\n'; output.currentLineWidth = 0; } else { // let's keep it all on the same line yet - output.text.push(' '); + yield ' '; } } } else { @@ -325,13 +349,13 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S const isWithinWhitespaceSensitiveNode = opts.newLines || (opts.indentSpaces ?? 0) > 0 || opts.prettyHtml ? isWithinWhitespaceSensitive(node) : false; if (opts.newLines && !isWithinWhitespaceSensitiveNode) { - output.text.push('\n'); + yield '\n'; output.currentLineWidth = 0; } if ((opts.indentSpaces ?? 0) > 0 && !isWithinWhitespaceSensitiveNode) { for (let i = 0; i < output.indent; i++) { - output.text.push(' '); + yield ' '; } output.currentLineWidth += output.indent; } @@ -348,9 +372,9 @@ function serializeToHtml(node: Node, opts: SerializeNodeToHtmlOptions, output: S // this text node cannot have its content escaped since it's going // into an element like
  • 2024 VW Vento
  • 2023 VW Beetle
"`; + +exports[`renderToString can render nested components 1`] = `""`; + +exports[`renderToString renders scoped component 1`] = `""`; + +exports[`renderToString supports passing props to components 1`] = `""`; + +exports[`renderToString supports passing props to components with a simple object 1`] = `""`; diff --git a/test/end-to-end/src/declarative-shadow-dom/another-car-detail.css b/test/end-to-end/src/declarative-shadow-dom/another-car-detail.css new file mode 100644 index 00000000000..665bc847f21 --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/another-car-detail.css @@ -0,0 +1,3 @@ +section { + color: green; +} \ No newline at end of file diff --git a/test/end-to-end/src/declarative-shadow-dom/another-car-detail.tsx b/test/end-to-end/src/declarative-shadow-dom/another-car-detail.tsx new file mode 100644 index 00000000000..7e5ed70d9f0 --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/another-car-detail.tsx @@ -0,0 +1,24 @@ +import { Component, h, Prop } from '@stencil/core'; + +import { CarData } from '../car-list/car-data'; + +@Component({ + tag: 'another-car-detail', + styleUrl: 'another-car-detail.css', + shadow: true, +}) +export class CarDetail { + @Prop() car: CarData; + + render() { + if (!this.car) { + return null; + } + + return ( +
+ {this.car.year} {this.car.make} {this.car.model} +
+ ); + } +} diff --git a/test/end-to-end/src/declarative-shadow-dom/another-car-list.css b/test/end-to-end/src/declarative-shadow-dom/another-car-list.css new file mode 100644 index 00000000000..94e1eb965cf --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/another-car-list.css @@ -0,0 +1,24 @@ + +:host { + display: block; + margin: 10px; + padding: 10px; + border: 1px solid blue; +} + +ul { + display: block; + margin: 0; + padding: 0; +} + +li { + list-style: none; + margin: 0; + padding: 20px; +} + +.selected { + font-weight: bold; + background: rgb(255, 255, 210); +} diff --git a/test/end-to-end/src/declarative-shadow-dom/another-car-list.tsx b/test/end-to-end/src/declarative-shadow-dom/another-car-list.tsx new file mode 100644 index 00000000000..8437de9c0e9 --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/another-car-list.tsx @@ -0,0 +1,46 @@ +import { Component, Event, EventEmitter, h, Prop } from '@stencil/core'; + +import { CarData } from '../car-list/car-data'; + +/** + * Component that helps display a list of cars + * @slot header - The slot for the header content. + * @part car - The shadow part to target to style the car. + */ +@Component({ + tag: 'another-car-list', + styleUrl: 'another-car-list.css', + shadow: true, +}) +export class CarList { + @Prop() cars: CarData[]; + @Prop({ mutable: true }) selected: CarData; + @Event() carSelected: EventEmitter; + + componentWillLoad() { + return new Promise((resolve) => setTimeout(resolve, 20)); + } + + selectCar(car: CarData) { + this.selected = car; + this.carSelected.emit(car); + } + + render() { + if (!Array.isArray(this.cars)) { + return null; + } + + return ( +
    + {this.cars.map((car) => { + return ( +
  • + +
  • + ); + })} +
+ ); + } +} diff --git a/test/end-to-end/src/declarative-shadow-dom/cmp-dsd.tsx b/test/end-to-end/src/declarative-shadow-dom/cmp-dsd.tsx new file mode 100644 index 00000000000..f1693706e9c --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/cmp-dsd.tsx @@ -0,0 +1,17 @@ +import { Component, h, Prop, State } from '@stencil/core'; + +@Component({ + tag: 'cmp-dsd', + shadow: true, +}) +export class ComponentDSD { + @State() + counter = 0; + + @Prop() + initialCounter = 0; + + render() { + return ; + } +} diff --git a/test/end-to-end/src/declarative-shadow-dom/readme.md b/test/end-to-end/src/declarative-shadow-dom/readme.md new file mode 100644 index 00000000000..763587e9cbb --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/readme.md @@ -0,0 +1,10 @@ +# cmp-server-vs-client + + + + + + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/test/end-to-end/src/declarative-shadow-dom/server-vs-client.tsx b/test/end-to-end/src/declarative-shadow-dom/server-vs-client.tsx new file mode 100644 index 00000000000..37eefd2ffc1 --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/server-vs-client.tsx @@ -0,0 +1,12 @@ +import { Component, h } from '@stencil/core'; + +@Component({ + tag: 'cmp-server-vs-client', + shadow: true, +}) +export class ServerVSClientCmp { + render() { + const winner = globalThis.constructor.name === 'MockWindow' ? 'Server' : 'Client'; + return
Server vs Client? Winner: {winner}
; + } +} diff --git a/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts new file mode 100644 index 00000000000..003d0c97a4b --- /dev/null +++ b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts @@ -0,0 +1,196 @@ +import { Readable } from 'node:stream'; + +import { newE2EPage } from '@stencil/core/testing'; + +import { CarData } from '../car-list/car-data'; + +const vento = new CarData('VW', 'Vento', 2024); +const beetle = new CarData('VW', 'Beetle', 2023); + +async function readableToString(readable: Readable) { + return new Promise((resolve, reject) => { + let data = ''; + + readable.on('data', (chunk) => { + data += chunk; + }); + + readable.on('end', () => { + resolve(data); + }); + + readable.on('error', (err) => { + reject(err); + }); + }); +} + +jest.setTimeout(3000000); + +// @ts-ignore may not be existing when project hasn't been built +type HydrateModule = typeof import('../../hydrate'); +let renderToString: HydrateModule['renderToString']; +let streamToString: HydrateModule['streamToString']; + +describe('renderToString', () => { + beforeAll(async () => { + // @ts-ignore may not be existing when project hasn't been built + const mod = await import('../../hydrate'); + renderToString = mod.renderToString; + streamToString = mod.streamToString; + }); + + it('can render a simple dom node', async () => { + const { html } = await renderToString('
Hello World
'); + expect(html).toContain('
Hello World
'); + }); + + it('can render a simple dom node (sync)', async () => { + const input = '
Hello World
'; + const renderedHTML = '
Hello World
'; + const stream = renderToString(input, {}, true); + expect(await readableToString(stream)).toContain(renderedHTML); + expect(await readableToString(streamToString(input))).toContain(renderedHTML); + }); + + it('renders scoped component', async () => { + const { html } = await renderToString('', { + serializeShadowRoot: true, + fullDocument: false, + }); + expect(html).toMatchSnapshot(); + }); + + it('supports passing props to components', async () => { + const { html } = await renderToString( + '', + { + serializeShadowRoot: true, + fullDocument: false, + }, + ); + expect(html).toMatchSnapshot(); + expect(html).toContain('2024 VW Vento'); + }); + + it('supports passing props to components with a simple object', async () => { + const { html } = await renderToString(``, { + serializeShadowRoot: true, + fullDocument: false, + }); + expect(html).toMatchSnapshot(); + expect(html).toContain('2024 VW Vento'); + }); + + it('does not fail if provided object is not a valid JSON', async () => { + const { html } = await renderToString( + ``, + { + serializeShadowRoot: true, + fullDocument: false, + }, + ); + expect(html).toContain('
'); + }); + + it('supports styles for DSD', async () => { + const { html } = await renderToString('', { + serializeShadowRoot: true, + fullDocument: false, + }); + expect(html).toContain('section.sc-another-car-detail{color:green}'); + }); + + it('only returns the element if we render to DSD', async () => { + const { html } = await renderToString('
Hello World
', { + serializeShadowRoot: true, + fullDocument: false, + }); + expect(html).toBe('
Hello World
'); + }); + + it('can render nested components', async () => { + const { html } = await renderToString( + ``, + { + serializeShadowRoot: true, + fullDocument: false, + }, + ); + expect(html).toMatchSnapshot(); + expect(html).toContain('2024 VW Vento'); + expect(html).toContain('2023 VW Beetle'); + }); + + it('can render a scoped component within a shadow component', async () => { + const { html } = await renderToString(``, { + serializeShadowRoot: true, + fullDocument: false, + }); + expect(html).toMatchSnapshot(); + expect(html).toContain( + '
2024 VW Vento
', + ); + expect(html).toContain( + '
2023 VW Beetle
', + ); + }); + + it('can render a scoped component within a shadow component (sync)', async () => { + const input = ``; + const expectedResults = [ + '
2024 VW Vento
', + '
2023 VW Beetle
', + ] as const; + const opts = { + serializeShadowRoot: true, + fullDocument: false, + }; + + const resultRenderToString = await readableToString(renderToString(input, opts, true)); + expect(resultRenderToString).toContain(expectedResults[0]); + expect(resultRenderToString).toContain(expectedResults[1]); + + const resultStreamToString = await readableToString(streamToString(input, opts)); + expect(resultStreamToString).toContain(expectedResults[0]); + expect(resultStreamToString).toContain(expectedResults[1]); + }); + + it('can take over a server side rendered component and re-render it in the browser', async () => { + const { html } = await renderToString('', { + serializeShadowRoot: true, + fullDocument: false, + }); + + expect(html).toContain('Count me: 0!'); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + const button = await page.find('cmp-dsd >>> button'); + await button.click(); + expect(button).toEqualText('Count me: 1!'); + }); + + it('can take over a server side rendered component and re-render it in the browser with applied prop', async () => { + const { html } = await renderToString('', { + serializeShadowRoot: true, + fullDocument: false, + }); + + expect(html).toContain('Count me: 42!'); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + const button = await page.find('cmp-dsd >>> button'); + await button.click(); + expect(button).toEqualText('Count me: 43!'); + }); + + it('can render server side component when client sender renders differently', async () => { + const { html } = await renderToString('', { + serializeShadowRoot: true, + fullDocument: false, + }); + + expect(html).toContain('Server vs Client? Winner: Server'); + const page = await newE2EPage({ html, url: 'https://stencil.com' }); + const button = await page.find('cmp-server-vs-client'); + expect(button.shadowRoot.querySelector('div')).toEqualText('Server vs Client? Winner: Client'); + }); +}); diff --git a/test/end-to-end/src/prerender-cmp/prerender-cmp.tsx b/test/end-to-end/src/prerender-cmp/prerender-cmp.tsx index b3a0f3f3625..d774e097396 100644 --- a/test/end-to-end/src/prerender-cmp/prerender-cmp.tsx +++ b/test/end-to-end/src/prerender-cmp/prerender-cmp.tsx @@ -1,4 +1,5 @@ import { Component, h } from '@stencil/core'; + import styles from './prerender-cmp.css'; @Component({ diff --git a/test/end-to-end/stencil.config.ts b/test/end-to-end/stencil.config.ts index 4f0e05fe8fa..e810f3266ea 100644 --- a/test/end-to-end/stencil.config.ts +++ b/test/end-to-end/stencil.config.ts @@ -1,9 +1,10 @@ -import { Config } from '../../internal'; -import builtins from 'rollup-plugin-node-builtins'; -import linaria from 'linaria/rollup'; -import css from 'rollup-plugin-css-only'; import { reactOutputTarget } from '@stencil/react-output-target'; +import linaria from 'linaria/rollup'; import path from 'path'; +import css from 'rollup-plugin-css-only'; +import builtins from 'rollup-plugin-node-builtins'; + +import { Config } from '../../internal'; export const config: Config = { namespace: 'EndToEnd', diff --git a/test/end-to-end/tsconfig.json b/test/end-to-end/tsconfig.json index 4084a890fbe..c67ece8da0c 100644 --- a/test/end-to-end/tsconfig.json +++ b/test/end-to-end/tsconfig.json @@ -9,6 +9,7 @@ "forceConsistentCasingInFileNames": true, "jsx": "react", "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", "lib": [ "dom", "es2017" diff --git a/test/wdio/declarative-shadow-dom/cmp.test.tsx b/test/wdio/declarative-shadow-dom/cmp.test.tsx new file mode 100644 index 00000000000..0d04224debf --- /dev/null +++ b/test/wdio/declarative-shadow-dom/cmp.test.tsx @@ -0,0 +1,17 @@ +import { render } from '@wdio/browser-runner/stencil'; + +import { renderToString } from '../hydrate/index.mjs'; + +describe('dsd-cmp', () => { + it('verifies that Stencil properly picks up the Declarative Shadow DOM', async () => { + const { html } = await renderToString(``, { + fullDocument: true, + serializeShadowRoot: true, + constrainTimeouts: false, + }); + + expect(html).toContain('I am rendered on the Server!'); + render({ html }); + await expect($('dsd-cmp')).toHaveText('I am rendered on the Client!'); + }); +}); diff --git a/test/wdio/declarative-shadow-dom/cmp.tsx b/test/wdio/declarative-shadow-dom/cmp.tsx new file mode 100644 index 00000000000..e815e9d855c --- /dev/null +++ b/test/wdio/declarative-shadow-dom/cmp.tsx @@ -0,0 +1,12 @@ +import { Component, h } from '@stencil/core'; + +@Component({ + tag: 'dsd-cmp', + shadow: true, +}) +export class DsdComponent { + render() { + const env = globalThis.constructor.name === 'MockWindow' ? 'Server' : 'Client'; + return
I am rendered on the {env}!
; + } +} diff --git a/test/wdio/prerender-test/cmp.test.tsx b/test/wdio/prerender-test/cmp.test.tsx index 142ce61dba7..f8a8755fd7c 100644 --- a/test/wdio/prerender-test/cmp.test.tsx +++ b/test/wdio/prerender-test/cmp.test.tsx @@ -105,7 +105,10 @@ describe('prerender', () => { expect(scopedStyle.color).toBe('rgb(255, 0, 0)'); const shadow = iframe.querySelector('cmp-client-shadow'); - const shadowStyle = getComputedStyle(shadow.shadowRoot.querySelector('article')); + await browser.waitUntil(async () => shadow.shadowRoot.querySelector('article')); + const article = shadow.shadowRoot.querySelector('article'); + + const shadowStyle = getComputedStyle(article); await browser.waitUntil(async () => shadowStyle.color === 'rgb(0, 155, 0)'); expect(shadowStyle.color).toBe('rgb(0, 155, 0)'); diff --git a/test/wdio/slot-reorder/cmp.test.tsx b/test/wdio/slot-reorder/cmp.test.tsx index a88b71f3f86..6060208c55d 100644 --- a/test/wdio/slot-reorder/cmp.test.tsx +++ b/test/wdio/slot-reorder/cmp.test.tsx @@ -20,10 +20,8 @@ describe('slot-reorder', () => { }); it('renders', async () => { - let r: HTMLElement; - function ordered() { - r = document.querySelector('.results1 div'); + let r = document.querySelector('.results1 div'); expect(r.children[0].textContent.trim()).toBe('fallback default'); expect(r.children[0].hasAttribute('hidden')).toBe(false); expect(r.children[0].getAttribute('name')).toBe(null); @@ -76,7 +74,7 @@ describe('slot-reorder', () => { } function reordered() { - r = document.querySelector('.results1 div'); + let r = document.querySelector('.results1 div'); expect(r.children[0].textContent.trim()).toBe('fallback slot-b'); expect(r.children[0].hasAttribute('hidden')).toBe(false); expect(r.children[0].getAttribute('name')).toBe('slot-b'); @@ -128,6 +126,7 @@ describe('slot-reorder', () => { expect(r.children[5].textContent.trim()).toBe('slot-a content'); } + await $('.results1 div').waitForExist(); ordered(); await $('button').click(); @@ -135,6 +134,7 @@ describe('slot-reorder', () => { return document.querySelector('div.reordered'); }); + await $('.results1 div').waitForExist(); reordered(); await $('button').click(); @@ -142,6 +142,7 @@ describe('slot-reorder', () => { return !document.querySelector('div.reordered'); }); + await $('.results1 div').waitForExist(); ordered(); await $('button').click(); @@ -149,6 +150,7 @@ describe('slot-reorder', () => { return document.querySelector('div.reordered'); }); + await $('.results1 div').waitForExist(); reordered(); }); }); diff --git a/test/wdio/stencil.config.ts b/test/wdio/stencil.config.ts index 50fc5ac31b8..d4454c70fd2 100644 --- a/test/wdio/stencil.config.ts +++ b/test/wdio/stencil.config.ts @@ -15,6 +15,10 @@ export const config: Config = { customElementsExportBehavior: 'bundle', isPrimaryPackageOutputTarget: true, }, + { + type: 'dist-hydrate-script', + dir: 'hydrate', + }, ], plugins: [sass()], buildDist: true, diff --git a/test/wdio/test-prerender/prerender.js b/test/wdio/test-prerender/prerender.js index 24cc100564c..c5989f955bf 100644 --- a/test/wdio/test-prerender/prerender.js +++ b/test/wdio/test-prerender/prerender.js @@ -11,8 +11,8 @@ async function run() { }); const filePath = path.join(__dirname, '..', 'www-prerender-script', 'prerender', 'index.html'); - const updatedHTML = results.html.replace(/(href|src)="\/prerender\//g, (a) => - a.replace('/prerender/', '/www-prerender-script/prerender/'), + const updatedHTML = results.html.replace(/(href|src)="\/prerender\//g, (html) => + html.replace('/prerender/', '/www-prerender-script/prerender/'), ); fs.writeFileSync(filePath, updatedHTML); diff --git a/test/wdio/util.ts b/test/wdio/util.ts index 6d20aedd064..c225a507883 100644 --- a/test/wdio/util.ts +++ b/test/wdio/util.ts @@ -13,11 +13,13 @@ export declare namespace SomeTypes { } export async function setupIFrameTest(htmlFile: string): Promise { - if (document.querySelector('iframe')) { - document.body.removeChild(document.querySelector('iframe')); + const oldFrame = document.querySelector('iframe'); + if (oldFrame) { + document.body.removeChild(oldFrame); } const htmlFilePath = path.resolve( + // @ts-ignore globalThis is a WebdriverIO global variable path.dirname(globalThis.__wdioSpec__), '..', htmlFile.slice(htmlFile.startsWith('/') ? 1 : 0), @@ -37,5 +39,5 @@ export async function setupIFrameTest(htmlFile: string): Promise { * wait for the iframe to load */ await new Promise((resolve) => (iframe.onload = resolve)); - return iframe.contentDocument.body; + return iframe.contentDocument!.body; } From 24a7ac6e0b128b4c8f69389c273fe93b23c40ce3 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Fri, 14 Jun 2024 13:31:41 -0700 Subject: [PATCH 2/5] Update src/runtime/bootstrap-lazy.ts Co-authored-by: Tanner Reits <47483144+tanner-reits@users.noreply.github.com> --- src/runtime/bootstrap-lazy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/bootstrap-lazy.ts b/src/runtime/bootstrap-lazy.ts index 5c28d23f75c..22677f85b2b 100644 --- a/src/runtime/bootstrap-lazy.ts +++ b/src/runtime/bootstrap-lazy.ts @@ -112,7 +112,7 @@ export const bootstrapLazy = (lazyBundles: d.LazyBundlesRuntimeData, options: d. if (supportsShadow) { if (!self.shadowRoot) { // we don't want to call `attachShadow` if there's already a shadow root - // attached to the + // attached to the component if (BUILD.shadowDelegatesFocus) { self.attachShadow({ mode: 'open', From 54908ffb479ae4360327f9619c2768bfebe4a793 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Fri, 14 Jun 2024 14:57:17 -0700 Subject: [PATCH 3/5] fix: strict null checks --- src/compiler/html/canonical-link.ts | 4 ++-- .../write-hydrate-outputs.ts | 3 +++ .../prerender/prerender-template-html.ts | 6 ++--- src/compiler/prerender/prerender-worker.ts | 2 +- src/declarations/stencil-private.ts | 22 +++++++++---------- src/hydrate/platform/proxy-host-element.ts | 8 +++---- src/hydrate/runner/hydrate-factory.ts | 6 +++-- .../runner/patch-dom-implementation.ts | 10 ++++----- src/hydrate/runner/render-utils.ts | 8 +++---- src/hydrate/runner/render.ts | 20 ++++++++--------- src/hydrate/runner/runtime-log.ts | 4 +++- src/hydrate/runner/window-initialize.ts | 15 ++++++------- src/mock-doc/document.ts | 2 +- src/mock-doc/serialize-node.ts | 7 ++++-- src/mock-doc/window.ts | 4 ++-- 15 files changed, 65 insertions(+), 56 deletions(-) diff --git a/src/compiler/html/canonical-link.ts b/src/compiler/html/canonical-link.ts index 7a22e2a66b6..0b9df789cca 100644 --- a/src/compiler/html/canonical-link.ts +++ b/src/compiler/html/canonical-link.ts @@ -1,4 +1,4 @@ -export const updateCanonicalLink = (doc: Document, href: string) => { +export const updateCanonicalLink = (doc: Document, href?: string) => { // https://webmasters.googleblog.com/2009/02/specify-your-canonical.html // let canonicalLinkElm = doc.head.querySelector('link[rel="canonical"]'); @@ -20,7 +20,7 @@ export const updateCanonicalLink = (doc: Document, href: string) => { // but there is a canonical link in the head so let's remove it const existingHref = canonicalLinkElm.getAttribute('href'); if (!existingHref) { - canonicalLinkElm.parentNode.removeChild(canonicalLinkElm); + canonicalLinkElm.parentNode?.removeChild(canonicalLinkElm); } } } diff --git a/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts b/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts index e0e5c75b87d..1e097a07ccf 100644 --- a/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts +++ b/src/compiler/output-targets/dist-hydrate-script/write-hydrate-outputs.ts @@ -29,6 +29,9 @@ const writeHydrateOutput = async ( const hydratePackageName = await getHydratePackageName(config, compilerCtx); const hydrateAppDirPath = outputTarget.dir; + if (!hydrateAppDirPath) { + throw new Error(`outputTarget config missing the "dir" property`); + } const hydrateCoreIndexPath = join(hydrateAppDirPath, 'index.js'); const hydrateCoreIndexPathESM = join(hydrateAppDirPath, 'index.mjs'); diff --git a/src/compiler/prerender/prerender-template-html.ts b/src/compiler/prerender/prerender-template-html.ts index b0bd526f55a..b3e201425e2 100644 --- a/src/compiler/prerender/prerender-template-html.ts +++ b/src/compiler/prerender/prerender-template-html.ts @@ -21,7 +21,7 @@ export const generateTemplateHtml = async ( manager: d.PrerenderManager, ) => { try { - if (!isString(srcIndexHtmlPath)) { + if (!isString(srcIndexHtmlPath) && outputTarget.indexHtml) { srcIndexHtmlPath = outputTarget.indexHtml; } @@ -52,7 +52,7 @@ export const generateTemplateHtml = async ( doc.documentElement.classList.add('hydrated'); - if (hydrateOpts.inlineExternalStyleSheets && !isDebug) { + if (hydrateOpts.inlineExternalStyleSheets && !isDebug && outputTarget.appDir) { try { await inlineExternalStyleSheets(config.sys, outputTarget.appDir, doc); } catch (e: any) { @@ -68,7 +68,7 @@ export const generateTemplateHtml = async ( } } - if (hydrateOpts.minifyStyleElements && !isDebug) { + if (hydrateOpts.minifyStyleElements && !isDebug && outputTarget.baseUrl) { try { const baseUrl = new URL(outputTarget.baseUrl, manager.devServerHostUrl); await minifyStyleElements(config.sys, outputTarget.appDir, doc, baseUrl, true); diff --git a/src/compiler/prerender/prerender-worker.ts b/src/compiler/prerender/prerender-worker.ts index f1dc300b1f5..915c4571cb6 100644 --- a/src/compiler/prerender/prerender-worker.ts +++ b/src/compiler/prerender/prerender-worker.ts @@ -101,7 +101,7 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d } if (hydrateOpts.addModulePreloads) { - if (!prerenderRequest.isDebug) { + if (!prerenderRequest.isDebug && componentGraph) { addModulePreloads(doc, hydrateOpts, hydrateResults, componentGraph); } } else { diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index 3ec06d5e894..bef8a8d2dfe 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1137,30 +1137,30 @@ export interface HostElement extends HTMLElement { ['s-p']?: Promise[]; - componentOnReady?: () => Promise; + componentOnReady?: () => Promise | undefined; } export interface HydrateResults { buildId: string; diagnostics: Diagnostic[]; url: string; - host: string; - hostname: string; - href: string; - port: string; - pathname: string; - search: string; - hash: string; - html: string; + host: string | null; + hostname: string | null; + href: string | null; + port: string | null; + pathname: string | null; + search: string | null; + hash: string | null; + html: string | null; components: HydrateComponent[]; anchors: HydrateAnchorElement[]; imgs: HydrateImgElement[]; scripts: HydrateScriptElement[]; styles: HydrateStyleElement[]; staticData: HydrateStaticData[]; - title: string; + title: string | null; hydratedCount: number; - httpStatus: number; + httpStatus: number | null; } export interface HydrateComponent { diff --git a/src/hydrate/platform/proxy-host-element.ts b/src/hydrate/platform/proxy-host-element.ts index 16ee6e5485c..06a7853d3b0 100644 --- a/src/hydrate/platform/proxy-host-element.ts +++ b/src/hydrate/platform/proxy-host-element.ts @@ -69,7 +69,7 @@ export function proxyHostElement( if (attrValue != null) { const parsedAttrValue = parsePropertyValue(attrValue, memberFlags); - hostRef.$instanceValues$.set(memberName, parsedAttrValue); + hostRef?.$instanceValues$?.set(memberName, parsedAttrValue); } const ownValue = (elm as any)[memberName]; @@ -77,7 +77,7 @@ export function proxyHostElement( // we've got an actual value already set on the host element // let's add that to our instance values and pull it off the element // so the getter/setter kicks in instead, but still getting this value - hostRef.$instanceValues$.set(memberName, ownValue); + hostRef?.$instanceValues$?.set(memberName, ownValue); delete (elm as any)[memberName]; } @@ -98,7 +98,7 @@ export function proxyHostElement( Object.defineProperty(elm, memberName, { value(this: d.HostElement, ...args: any[]) { const ref = getHostRef(this); - return ref.$onInstancePromise$.then(() => ref.$lazyInstance$[memberName](...args)).catch(consoleError); + return ref?.$onInstancePromise$?.then(() => ref?.$lazyInstance$?.[memberName](...args)).catch(consoleError); }, }); } @@ -107,7 +107,7 @@ export function proxyHostElement( } function componentOnReady(this: d.HostElement) { - return getHostRef(this).$onReadyPromise$; + return getHostRef(this)?.$onReadyPromise$; } function forceUpdate(this: d.HostElement) { diff --git a/src/hydrate/runner/hydrate-factory.ts b/src/hydrate/runner/hydrate-factory.ts index eee724980de..23b797e8129 100644 --- a/src/hydrate/runner/hydrate-factory.ts +++ b/src/hydrate/runner/hydrate-factory.ts @@ -1,11 +1,13 @@ +import { MockWindow } from '@stencil/core/mock-doc'; + import type * as d from '../../declarations'; export function hydrateFactory( - win: Window, + win: MockWindow, opts: d.HydrateDocumentOptions, results: d.HydrateResults, afterHydrate: ( - win: Window, + win: MockWindow, opts: DocOptions, results: d.HydrateResults, resolve: (results: d.HydrateResults) => void, diff --git a/src/hydrate/runner/patch-dom-implementation.ts b/src/hydrate/runner/patch-dom-implementation.ts index d356225a701..eaa05f4ac8f 100644 --- a/src/hydrate/runner/patch-dom-implementation.ts +++ b/src/hydrate/runner/patch-dom-implementation.ts @@ -3,7 +3,7 @@ import { MockWindow, patchWindow } from '@stencil/core/mock-doc'; import type * as d from '../../declarations'; export function patchDomImplementation(doc: any, opts: d.HydrateFactoryOptions) { - let win: any; + let win: MockWindow; if (doc.defaultView != null) { opts.destroyWindow = true; @@ -38,7 +38,7 @@ export function patchDomImplementation(doc: any, opts: d.HydrateFactoryOptions) } try { - // Assigning the baseURI prevents JavaScript optimizers from treating this as dead code + // @ts-expect-error Assigning the baseURI prevents JavaScript optimizers from treating this as dead code win.__stencil_baseURI = doc.baseURI; } catch (e) { Object.defineProperty(doc, 'baseURI', { @@ -52,13 +52,13 @@ export function patchDomImplementation(doc: any, opts: d.HydrateFactoryOptions) }); } - return win as Window & typeof globalThis; + return win; } -function getRootNode(opts?: { composed?: boolean; [key: string]: any }) { +function getRootNode(this: Node, opts?: { composed?: boolean; [key: string]: any }) { const isComposed = opts != null && opts.composed === true; - let node: Node = this as any; + let node: Node = this; while (node.parentNode != null) { node = node.parentNode; diff --git a/src/hydrate/runner/render-utils.ts b/src/hydrate/runner/render-utils.ts index 9ddc82be65e..40e79bcece7 100644 --- a/src/hydrate/runner/render-utils.ts +++ b/src/hydrate/runner/render-utils.ts @@ -1,6 +1,6 @@ import type * as d from '../../declarations'; -export function normalizeHydrateOptions(inputOpts: d.HydrateDocumentOptions) { +export function normalizeHydrateOptions(inputOpts?: d.HydrateDocumentOptions) { const outputOpts: d.HydrateFactoryOptions = Object.assign( { serializeToHtml: false, @@ -144,12 +144,12 @@ export function renderBuildDiagnostic( return diagnostic; } -export function renderBuildError(results: d.HydrateResults, msg: string) { - return renderBuildDiagnostic(results, 'error', 'Hydrate Error', msg); +export function renderBuildError(results: d.HydrateResults, msg?: string) { + return renderBuildDiagnostic(results, 'error', 'Hydrate Error', msg || ''); } export function renderCatchError(results: d.HydrateResults, err: any) { - const diagnostic = renderBuildError(results, null); + const diagnostic = renderBuildError(results); if (err != null) { if (err.stack != null) { diff --git a/src/hydrate/runner/render.ts b/src/hydrate/runner/render.ts index 6ac7b0ae97a..ad9a7dd2fe8 100644 --- a/src/hydrate/runner/render.ts +++ b/src/hydrate/runner/render.ts @@ -33,7 +33,7 @@ export function renderToString(html: string | any, options?: SerializeDocumentOp export function renderToString( html: string | any, options?: SerializeDocumentOptions, - asStream?: true, + asStream?: boolean, ): Promise | Readable { const opts = normalizeHydrateOptions(options); /** @@ -59,17 +59,17 @@ export function renderToString( export function hydrateDocument( doc: any | string, options: HydrateDocumentOptions | undefined, - asStream: true, + asStream?: boolean, ): Readable; export function hydrateDocument(doc: any | string, options?: HydrateDocumentOptions): Promise; export function hydrateDocument( doc: any | string, options?: HydrateDocumentOptions, - asStream?: true, + asStream?: boolean, ): Promise | Readable { const opts = normalizeHydrateOptions(options); - let win: Window & typeof globalThis; + let win: MockWindow | null = null; const results = generateHydrateResults(opts); if (hasError(results.diagnostics)) { @@ -80,7 +80,7 @@ export function hydrateDocument( try { opts.destroyWindow = true; opts.destroyDocument = true; - win = new MockWindow(doc) as any; + win = new MockWindow(doc); if (!asStream) { return render(win, opts, results).then(() => results); @@ -121,7 +121,7 @@ export function hydrateDocument( return Promise.resolve(results); } -async function render(win: Window & typeof globalThis, opts: HydrateFactoryOptions, results: HydrateResults) { +async function render(win: MockWindow, opts: HydrateFactoryOptions, results: HydrateResults) { if ('process' in globalThis && typeof process.on === 'function' && !(process as any).__stencilErrors) { (process as any).__stencilErrors = true; process.on('unhandledRejection', (e) => { @@ -147,7 +147,7 @@ async function render(win: Window & typeof globalThis, opts: HydrateFactoryOptio * @param results render result object * @returns a Readable that can be passed into a response */ -function renderStream(win: Window & typeof globalThis, opts: HydrateFactoryOptions, results: HydrateResults) { +function renderStream(win: MockWindow, opts: HydrateFactoryOptions, results: HydrateResults) { async function* processRender() { const renderResult = await render(win, opts, results); yield renderResult.html; @@ -157,7 +157,7 @@ function renderStream(win: Window & typeof globalThis, opts: HydrateFactoryOptio } async function afterHydrate( - win: Window, + win: MockWindow, opts: HydrateFactoryOptions, results: HydrateResults, resolve: (results: HydrateResults) => void, @@ -172,7 +172,7 @@ async function afterHydrate( } } -function finalizeHydrate(win: Window, doc: Document, opts: HydrateFactoryOptions, results: HydrateResults) { +function finalizeHydrate(win: MockWindow, doc: Document, opts: HydrateFactoryOptions, results: HydrateResults) { try { inspectElement(results, doc.documentElement, 0); @@ -237,7 +237,7 @@ function finalizeHydrate(win: Window, doc: Document, opts: HydrateFactoryOptions return results; } -function destroyWindow(win: Window, doc: Document, opts: HydrateFactoryOptions, results: HydrateResults) { +function destroyWindow(win: MockWindow, doc: Document, opts: HydrateFactoryOptions, results: HydrateResults) { if (!opts.destroyWindow) { return; } diff --git a/src/hydrate/runner/runtime-log.ts b/src/hydrate/runner/runtime-log.ts index ea189f13235..79759c1cc25 100644 --- a/src/hydrate/runner/runtime-log.ts +++ b/src/hydrate/runner/runtime-log.ts @@ -1,8 +1,10 @@ +import { MockWindow } from '@stencil/core/mock-doc'; + import type * as d from '../../declarations'; import { renderBuildDiagnostic, renderCatchError } from './render-utils'; export function runtimeLogging( - win: Window & typeof globalThis, + win: MockWindow, opts: d.HydrateDocumentOptions, results: d.HydrateResults, ) { diff --git a/src/hydrate/runner/window-initialize.ts b/src/hydrate/runner/window-initialize.ts index 099632ef4c4..f8b66788480 100644 --- a/src/hydrate/runner/window-initialize.ts +++ b/src/hydrate/runner/window-initialize.ts @@ -1,24 +1,23 @@ -import { constrainTimeouts } from '@stencil/core/mock-doc'; +import { constrainTimeouts, type MockWindow } from '@stencil/core/mock-doc'; import type * as d from '../../declarations'; -import { renderCatchError } from './render-utils'; import { runtimeLogging } from './runtime-log'; export function initializeWindow( - win: Window & typeof globalThis, + win: MockWindow, doc: Document, opts: d.HydrateDocumentOptions, results: d.HydrateResults, ) { - try { - win.location.href = opts.url; - } catch (e) { - renderCatchError(results, e); + if (typeof opts.url === 'string') { + try { + win.location.href = opts.url; + } catch (e) {} } if (typeof opts.userAgent === 'string') { try { - (win.navigator as any).userAgent = opts.userAgent; + win.navigator.userAgent = opts.userAgent; } catch (e) {} } if (typeof opts.cookie === 'string') { diff --git a/src/mock-doc/document.ts b/src/mock-doc/document.ts index 9077c79b0c2..8286c021eb9 100644 --- a/src/mock-doc/document.ts +++ b/src/mock-doc/document.ts @@ -15,7 +15,7 @@ export class MockDocument extends MockHTMLElement { cookie: string; referrer: string; - constructor(html: string | boolean = null, win: any = null) { + constructor(html: string | boolean | null = null, win: any = null) { super(null, null); this.nodeName = NODE_NAMES.DOCUMENT_NODE; this.nodeType = NODE_TYPES.DOCUMENT_NODE; diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index 120266a54f9..0216f664bf7 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -451,7 +451,10 @@ function* streamToHtml( } yield ''; - output.currentLineWidth += nodeValue.length + 7; + if (nodeValue) { + output.currentLineWidth += nodeValue.length + 7; + } + } else if (node.nodeType === NODE_TYPES.DOCUMENT_TYPE_NODE) { yield ''; } @@ -492,7 +495,7 @@ function escapeString(str: string, attrMode: boolean) { */ function isWithinWhitespaceSensitive(node: Node | MockNode) { let _node: Node | MockNode | null = node; - while (_node != null) { + while (_node?.nodeName) { if (WHITESPACE_SENSITIVE.has(_node.nodeName)) { return true; } diff --git a/src/mock-doc/window.ts b/src/mock-doc/window.ts index 0d0701eb58c..6dded0f0f2b 100644 --- a/src/mock-doc/window.ts +++ b/src/mock-doc/window.ts @@ -853,12 +853,12 @@ export function cloneWindow(srcWin: Window, opts: { customElementProxy?: boolean } export function cloneDocument(srcDoc: Document) { - if (srcDoc == null) { + if (srcDoc == null || !srcDoc.defaultView) { return null; } const dstWin = cloneWindow(srcDoc.defaultView); - return dstWin.document; + return dstWin?.document || null; } // TODO(STENCIL-345) - Evaluate reconciling MockWindow, Window differences From 059de43e16a2b1dba2ef1467d7e6f43da2b07353 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Fri, 14 Jun 2024 15:14:29 -0700 Subject: [PATCH 4/5] prettier --- src/hydrate/runner/runtime-log.ts | 6 +----- src/mock-doc/serialize-node.ts | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/hydrate/runner/runtime-log.ts b/src/hydrate/runner/runtime-log.ts index 79759c1cc25..83ed3ec4018 100644 --- a/src/hydrate/runner/runtime-log.ts +++ b/src/hydrate/runner/runtime-log.ts @@ -3,11 +3,7 @@ import { MockWindow } from '@stencil/core/mock-doc'; import type * as d from '../../declarations'; import { renderBuildDiagnostic, renderCatchError } from './render-utils'; -export function runtimeLogging( - win: MockWindow, - opts: d.HydrateDocumentOptions, - results: d.HydrateResults, -) { +export function runtimeLogging(win: MockWindow, opts: d.HydrateDocumentOptions, results: d.HydrateResults) { try { const pathname = win.location.pathname; diff --git a/src/mock-doc/serialize-node.ts b/src/mock-doc/serialize-node.ts index 0216f664bf7..61f51c13e77 100644 --- a/src/mock-doc/serialize-node.ts +++ b/src/mock-doc/serialize-node.ts @@ -454,7 +454,6 @@ function* streamToHtml( if (nodeValue) { output.currentLineWidth += nodeValue.length + 7; } - } else if (node.nodeType === NODE_TYPES.DOCUMENT_TYPE_NODE) { yield ''; } From 114bbad06f6c60111f99d4e90b280f81512e931a Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Fri, 14 Jun 2024 15:38:02 -0700 Subject: [PATCH 5/5] remove setTimeout call --- test/end-to-end/src/declarative-shadow-dom/test.e2e.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts index 003d0c97a4b..e4fcb9f292c 100644 --- a/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts +++ b/test/end-to-end/src/declarative-shadow-dom/test.e2e.ts @@ -25,8 +25,6 @@ async function readableToString(readable: Readable) { }); } -jest.setTimeout(3000000); - // @ts-ignore may not be existing when project hasn't been built type HydrateModule = typeof import('../../hydrate'); let renderToString: HydrateModule['renderToString'];