diff --git a/cspell-wordlist.txt b/cspell-wordlist.txt index 7d3216e13a3a..018cf5818965 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 153bed5469a0..4337a10419f1 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 73b4c5a1f7a6..27f39233d589 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 33aef31c8e77..e0e5c75b87df 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 7c27466dd20a..b0bd526f55a6 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 c884a52c1638..f1dc300b1f53 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 faa46e0d0235..3ec06d5e894d 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 b6a752861357..709ca74cf9b6 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 928c96a94688..a60ca5c9560f 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 92b6e8bb5431..c0f544e6048c 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 386d0e93ef9f..536fa8bcc722 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 40197dd98a0a..16ee6e5485ca 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 27b73fa0d44d..ca11c860a5de 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 74e9576e74a6..6ac7b0ae97a1 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 ba1e17977959..da8f2ad81436 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 ee75ed901cb1..120266a54f9b 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 000000000000..665bc847f210 --- /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 000000000000..7e5ed70d9f02 --- /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 000000000000..94e1eb965cf3 --- /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 000000000000..8437de9c0e94 --- /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 000000000000..f1693706e9c2 --- /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 000000000000..763587e9cbbf --- /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 000000000000..37eefd2ffc1b --- /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 000000000000..003d0c97a4bb --- /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 b3a0f3f3625a..d774e0973967 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 4f0e05fe8fad..e810f3266ea0 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 4084a890fbef..c67ece8da0cc 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 000000000000..0d04224debf0 --- /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 000000000000..e815e9d855cb --- /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 142ce61dba7c..f8a8755fd7c2 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 a88b71f3f86d..6060208c55db 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 50fc5ac31b85..d4454c70fd2a 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 24cc100564c6..c5989f955bf0 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 6d20aedd0641..c225a507883a 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; }