diff --git a/src/bootloader-shared.ts b/src/bootloader-shared.ts index 8433d6c3388..fbf2eb80e27 100644 --- a/src/bootloader-shared.ts +++ b/src/bootloader-shared.ts @@ -24,29 +24,11 @@ * @param href * @returns */ -export const qrlResolver = ( - doc: Document, - element: Element | null, - eventUrl?: string | null, - _url?: string, - _base?: string | URL -): URL | undefined => { - if (eventUrl === undefined) { - // recursive call - if (element) { - _url = element.getAttribute('q:base')!; - _base = qrlResolver( - doc, - element.parentNode && (element.parentNode as HTMLElement).closest('[q\\:base]') - ); - } else { - _url = doc.baseURI; - } - } else if (eventUrl) { - _url = eventUrl; - _base = qrlResolver(doc, element!.closest('[q\\:base]')); - } - return _url ? new URL(_url, _base) : undefined; +export const qrlResolver = (element: Element, eventUrl: string): URL => { + const doc = element.ownerDocument!; + const containerEl = element.closest('[q\\:container]'); + const base = new URL(containerEl?.getAttribute('q:base') ?? doc.baseURI, doc.baseURI); + return new URL(eventUrl, base); }; const error = (msg: string) => { @@ -92,7 +74,7 @@ export const qwikLoader = (doc: Document, hasInitialized?: boolean | number) => ev.preventDefault(); } for (const qrl of attrValue.split('\n')) { - const url = qrlResolver(doc, element, qrl); + const url = qrlResolver(element, qrl); if (url) { const symbolName = getSymbolName(url); const module = @@ -223,7 +205,7 @@ export const setupPrefetching = ( const name = attr.name; const value = attr.value; if (name.startsWith('on:') && value) { - const url = qrlResolver(doc, element, value)!; + const url = qrlResolver(element, value)!; url.hash = url.search = ''; const key = url.toString() + '.js'; if (!qrlCache[key]) { diff --git a/src/bootloader-shared.unit.ts b/src/bootloader-shared.unit.ts index 3231d9efcb6..402d4678e9e 100644 --- a/src/bootloader-shared.unit.ts +++ b/src/bootloader-shared.unit.ts @@ -100,18 +100,19 @@ describe('qwikloader', () => { it('should resolve full URL', () => { const div = doc.createElement('div'); - expect(String(qrlResolver(doc, div, 'http://foo.bar/baz'))).toEqual('http://foo.bar/baz'); + expect(String(qrlResolver(div, 'http://foo.bar/baz'))).toEqual('http://foo.bar/baz'); }); it('should resolve relative URL against base', () => { const div = doc.createElement('div'); - expect(String(qrlResolver(doc, div, './bar'))).toEqual('http://document.qwik.dev/bar'); + expect(String(qrlResolver(div, './bar'))).toEqual('http://document.qwik.dev/bar'); }); it('should resolve relative URL against q:base', () => { const div = doc.createElement('div'); - div.setAttribute('q:base', '../baz/'); - expect(String(qrlResolver(doc, div, './bar'))).toEqual('http://document.qwik.dev/baz/bar'); + div.setAttribute('q:container', ''); + div.setAttribute('q:base', '/baz/'); + expect(String(qrlResolver(div, './bar'))).toEqual('http://document.qwik.dev/baz/bar'); }); it('should resolve relative URL against nested q:base', () => { @@ -119,17 +120,9 @@ describe('qwikloader', () => { const parent = doc.createElement('parent'); doc.body.appendChild(parent); parent.appendChild(div); + parent.setAttribute('q:container', ''); parent.setAttribute('q:base', './parent/'); - div.setAttribute('q:base', './child/'); - expect(String(qrlResolver(doc, div, './bar'))).toEqual( - 'http://document.qwik.dev/parent/child/bar' - ); - }); - - it('do nothing for null/undefined/empty string', () => { - const div = doc.createElement('div'); - expect(qrlResolver(doc, null, null)).toBeFalsy(); - expect(qrlResolver(doc, div, '')).toBeFalsy(); + expect(String(qrlResolver(div, './bar'))).toEqual('http://document.qwik.dev/parent/bar'); }); }); diff --git a/src/core/component/component-ctx.ts b/src/core/component/component-ctx.ts index 61241741d96..074fd1ff45b 100644 --- a/src/core/component/component-ctx.ts +++ b/src/core/component/component-ctx.ts @@ -7,6 +7,12 @@ import { styleContent, styleHost } from './qrl-styles'; import { newInvokeContext, useInvoke } from '../use/use-core'; import type { QContext } from '../props/props'; import { processNode } from '../render/jsx/jsx-runtime'; +import type { QRLInternal } from '../import/qrl-class'; + +export interface RenderFactoryOutput { + renderQRL: QRLInternal; + waitOn: any[]; +} export const firstRenderComponent = (rctx: RenderContext, ctx: QContext) => { ctx.element.setAttribute(QHostAttr, ''); diff --git a/src/core/component/component.public.ts b/src/core/component/component.public.ts index f1be3aae495..e9cff185fa9 100644 --- a/src/core/component/component.public.ts +++ b/src/core/component/component.public.ts @@ -1,9 +1,9 @@ import { toQrlOrError } from '../import/qrl'; import type { QRLInternal } from '../import/qrl-class'; import { $, implicit$FirstArg, QRL } from '../import/qrl.public'; -import { qPropWriteQRL, qrlFactory } from '../props/props-on'; +import { qPropWriteQRL } from '../props/props-on'; import type { JSXNode } from '../render/jsx/types/jsx-node'; -import { newInvokeContext, useInvoke, useWaitOn } from '../use/use-core'; +import { newInvokeContext, StyleAppend, useInvoke, useWaitOn } from '../use/use-core'; import { useHostElement } from '../use/use-host-element.public'; import { ComponentScopedStyles, OnRenderProp } from '../util/markers'; import { styleKey } from './qrl-styles'; @@ -14,6 +14,8 @@ import type { FunctionComponent } from '../index'; import { jsx } from '../render/jsx/jsx-runtime'; import { getDocument } from '../util/dom'; +import { promiseAll } from '../util/promises'; +import type { RenderFactoryOutput } from './component-ctx'; // // !!DO NOT EDIT THIS COMMENT DIRECTLY!!! @@ -323,14 +325,17 @@ export function componentQrl( // Return a QComponent Factory function. return function QComponent(props, key): JSXNode { - const onRenderFactory: qrlFactory = async (hostElement: Element): Promise => { - // Turn function into QRL + const onRenderFactory = async (hostElement: Element): Promise => { const onMountQrl = toQrlOrError(onMount); const onMountFn = await resolveQrl(hostElement, onMountQrl); const ctx = getContext(hostElement); const props = getProps(ctx) as any; const invokeCtx = newInvokeContext(getDocument(hostElement), hostElement, hostElement); - return useInvoke(invokeCtx, onMountFn, props) as QRLInternal; + const renderQRL = (await useInvoke(invokeCtx, onMountFn, props)) as QRLInternal; + return { + renderQRL, + waitOn: await promiseAll(invokeCtx.waitOn || []), + }; }; onRenderFactory.__brand__ = 'QRLFactory'; @@ -433,14 +438,12 @@ function _useStyles(styles: QRL, scoped: boolean) { useWaitOn( styleQrl.resolve(hostElement).then((styleText) => { - const document = getDocument(hostElement); - const head = document.querySelector('head'); - if (head && !head.querySelector(`style[q\\:style="${styleId}"]`)) { - const style = document.createElement('style'); - style.setAttribute('q:style', styleId); - style.textContent = scoped ? styleText.replace(/�/g, styleId) : styleText; - head.appendChild(style); - } + const task: StyleAppend = { + type: 'style', + scope: styleId, + content: scoped ? styleText.replace(/�/g, styleId) : styleText, + }; + return task; }) ); } diff --git a/src/core/component/mock.unit.css b/src/core/component/mock.unit.css index f9ace42ca82..fd144d4fe88 100644 --- a/src/core/component/mock.unit.css +++ b/src/core/component/mock.unit.css @@ -1,12 +1,12 @@ /** * Sample file demonstrating what the CSS may look like * - * - 📦: Is prefixed to host elements and is the replacement for the `:host` selector. - * - 🏷️: Is prefixed to all other elements. + * - 💎: Is prefixed to host elements and is the replacement for the `:host` selector. + * - ⭐️: Is prefixed to all other elements. */ -📦ABC123 { +💎ABC123 { } -🏷️ABC123 { +⭐️ABC123 { } diff --git a/src/core/object/store.public.ts b/src/core/object/store.public.ts index 9cc67994a05..56966a4c493 100644 --- a/src/core/object/store.public.ts +++ b/src/core/object/store.public.ts @@ -9,7 +9,7 @@ import { snapshotState } from './store'; * @public */ export function snapshot(elmOrDoc: Element | Document) { - const doc = isDocument(elmOrDoc) ? elmOrDoc : getDocument(elmOrDoc); + const doc = getDocument(elmOrDoc); const data = snapshotState(elmOrDoc); const parentJSON = isDocument(elmOrDoc) ? elmOrDoc.body : elmOrDoc; const script = doc.createElement('script'); diff --git a/src/core/object/store.ts b/src/core/object/store.ts index 37f4788add3..4c08988c835 100644 --- a/src/core/object/store.ts +++ b/src/core/object/store.ts @@ -6,7 +6,13 @@ import { getContext } from '../props/props'; import { getDocument } from '../util/dom'; import { isDocument, isElement } from '../util/element'; import { logError, logWarn } from '../util/log'; -import { ELEMENT_ID, ELEMENT_ID_PREFIX, QHostAttr, QObjAttr } from '../util/markers'; +import { + ELEMENT_ID, + ELEMENT_ID_PREFIX, + QContainerAttr, + QHostAttr, + QObjAttr, +} from '../util/markers'; import { qDev } from '../util/qdev'; import { getProxyMap, @@ -34,7 +40,7 @@ export function resume(elmOrDoc: Element | Document) { // logWarn('Skipping hydration because parent element is not q:container'); return; } - const doc = isDocument(elmOrDoc) ? elmOrDoc : getDocument(elmOrDoc); + const doc = getDocument(elmOrDoc); const isDoc = isDocument(elmOrDoc) || elmOrDoc === doc.documentElement; const parentJSON = isDoc ? doc.body : parentElm; const script = getQwikJSON(parentJSON); @@ -90,7 +96,7 @@ export function resume(elmOrDoc: Element | Document) { } export function snapshotState(elmOrDoc: Element | Document) { - const doc = isDocument(elmOrDoc) ? elmOrDoc : getDocument(elmOrDoc); + const doc = getDocument(elmOrDoc); const parentElm = isDocument(elmOrDoc) ? elmOrDoc.documentElement : elmOrDoc; const proxyMap = getProxyMap(doc); const objSet = new Set(); @@ -427,7 +433,7 @@ export function isProxy(obj: any): boolean { } export function isContainer(el: Element) { - return el.hasAttribute('q:container'); + return el.hasAttribute(QContainerAttr); } function hasQObj(el: Element) { diff --git a/src/core/platform/platform.ts b/src/core/platform/platform.ts index c3bce5245f7..fada74c79ab 100644 --- a/src/core/platform/platform.ts +++ b/src/core/platform/platform.ts @@ -1,5 +1,5 @@ +import { getContainer } from '../use/use-core'; import { getDocument } from '../util/dom'; -import { isDocument } from '../util/element'; import type { CorePlatform } from './types'; export const createPlatform = (doc: Document): CorePlatform => { @@ -52,27 +52,10 @@ export const createPlatform = (doc: Document): CorePlatform => { * @param url - relative URL * @returns fully qualified URL. */ -export function toUrl(doc: Document, element: Element | null, url?: string | URL): URL { - let _url: string | URL; - let _base: string | URL | undefined = undefined; - - if (url === undefined) { - // recursive call - if (element) { - _url = element.getAttribute('q:base')!; - _base = toUrl( - doc, - element.parentNode && (element.parentNode as HTMLElement).closest('[q\\:base]') - ); - } else { - _url = doc.baseURI; - } - } else if (url) { - (_url = url), (_base = toUrl(doc, element!.closest('[q\\:base]'))); - } else { - throw new Error('INTERNAL ERROR'); - } - return new URL(String(_url), _base); +export function toUrl(doc: Document, element: Element, url: string | URL): URL { + const containerEl = getContainer(element); + const base = new URL(containerEl?.getAttribute('q:base') ?? doc.baseURI, doc.baseURI); + return new URL(url, base); } /** @@ -85,7 +68,7 @@ export const setPlatform = (doc: Document, plt: CorePlatform) => * @public */ export const getPlatform = (docOrNode: Document | Node) => { - const doc = (isDocument(docOrNode) ? docOrNode : getDocument(docOrNode)!) as PlatformDocument; + const doc = getDocument(docOrNode) as PlatformDocument; return doc[DocumentPlatform] || (doc[DocumentPlatform] = createPlatform(doc)); }; diff --git a/src/core/props/props-on.ts b/src/core/props/props-on.ts index 3d3950694e0..3b23b5413b4 100644 --- a/src/core/props/props-on.ts +++ b/src/core/props/props-on.ts @@ -25,21 +25,6 @@ export function isOn$Prop(prop: string): boolean { return ON$_PROP_REGEX.test(prop); } -/** - * In the case of a component, it is necessary to have `on:q-render` value. - * However the `component` can run when parent component is rendering only to - * realize that `on:q-render` already exists. This interface exists to solve that - * problem. - * - * A parent component's `component` returns a `qrlFactory` for `on:q-render`. The - * `getProps` than looks to see if it already has a resolved value, and if so the - * `qrlFactory` is ignored, otherwise the `qrlFactory` is used to recover the `QRLInternal`. - */ -export interface qrlFactory { - __brand__: `QRLFactory`; - (element: Element): Promise>; -} - export function qPropReadQRL( ctx: QContext, prop: string diff --git a/src/core/props/props.ts b/src/core/props/props.ts index 10f2a1a89c5..cc851f9b91c 100644 --- a/src/core/props/props.ts +++ b/src/core/props/props.ts @@ -4,8 +4,6 @@ import { getProxyMap, readWriteProxy } from '../object/q-object'; import { resume } from '../object/store'; import type { RenderContext } from '../render/cursor'; import { getDocument } from '../util/dom'; -import { isDocument } from '../util/element'; -import { logWarn } from '../util/log'; import { newQObjectMap, QObjectMap } from './props-obj-map'; import { qPropWriteQRL, qPropReadQRL } from './props-on'; import type { QRLInternal } from '../import/qrl-class'; @@ -15,17 +13,11 @@ Error.stackTraceLimit = 9999; const Q_IS_RESUMED = '__isResumed__'; const Q_CTX = '__ctx__'; -export function resumeIfNeeded(elm: Element | Document): void { - const doc = isDocument(elm) ? elm : getDocument(elm); - const root = isDocument(elm) ? elm : elm.closest('[q\\:container]') ?? doc; - if (!root) { - logWarn('cant find qwik app root'); - return; - } - const isHydrated = (root as any)[Q_IS_RESUMED]; +export function resumeIfNeeded(containerEl: Element): void { + const isHydrated = (containerEl as any)[Q_IS_RESUMED]; if (!isHydrated) { - (root as any)[Q_IS_RESUMED] = true; - resume(root); + (containerEl as any)[Q_IS_RESUMED] = true; + resume(containerEl); } } diff --git a/src/core/render/cursor.ts b/src/core/render/cursor.ts index e6815326493..eed36836296 100644 --- a/src/core/render/cursor.ts +++ b/src/core/render/cursor.ts @@ -1,13 +1,15 @@ import { OnRenderProp, QSlotAttr } from '../util/markers'; import { ComponentCtx, getContext, getProps, QContext, setEvent } from '../props/props'; import { isOn$Prop, isOnProp } from '../props/props-on'; -export const SVG_NS = 'http://www.w3.org/2000/svg'; import type { ValueOrPromise } from '../util/types'; import type { JSXNode } from '../render/jsx/types/jsx-node'; import { Host } from '../render/jsx/host.public'; import { $ } from '../import/qrl.public'; - -import { firstRenderComponent, renderComponent } from '../component/component-ctx'; +import { + firstRenderComponent, + renderComponent, + RenderFactoryOutput, +} from '../component/component-ctx'; import { promiseAll, then } from '../util/promises'; import type { RenderingState } from './notify-render'; import { assertDefined, assertEqual } from '../assert/assert'; @@ -19,6 +21,9 @@ import { logDebug, logError, logWarn } from '../util/log'; import { qDev } from '../util/qdev'; import { qError, QError } from '../error/error'; import { fromCamelToKebabCase } from '../util/case'; +import { isStyleTask, StyleAppend } from '../use/use-core'; + +export const SVG_NS = 'http://www.w3.org/2000/svg'; type KeyToIndexMap = { [key: string]: number }; @@ -50,6 +55,7 @@ export interface RenderContext { operations: RenderOperation[]; component: ComponentCtx | undefined; globalState: RenderingState; + containerEl: Element; perf: RenderPerf; } @@ -475,10 +481,15 @@ function createElm(rctx: RenderContext, vnode: JSXNode, isSvg: boolean): ValueOr let wait: ValueOrPromise; if (isComponent) { // Run mount hook - const renderQRLPromise = props![OnRenderProp]!(elm); - wait = then(renderQRLPromise, (renderQrl) => { - ctx.renderQrl = renderQrl; - ctx.refMap.add(renderQrl); + const renderQRLPromise = props![OnRenderProp]!(elm) as Promise; + wait = then(renderQRLPromise, (output) => { + ctx.renderQrl = output.renderQRL; + output.waitOn.forEach((task) => { + if (isStyleTask(task)) { + appendStyle(rctx, elm, task); + } + }); + ctx.refMap.add(output.renderQRL); return firstRenderComponent(rctx, ctx); }); } else { @@ -746,6 +757,24 @@ function insertBefore( return newChild; } +function appendStyle(ctx: RenderContext, hostElement: Element, styleTask: StyleAppend) { + const fn = () => { + const containerEl = ctx.containerEl; + if (!containerEl.querySelector(`style[q\\:style="${styleTask.scope}"]`)) { + const style = ctx.doc.createElement('style'); + const stylesParent = ctx.doc.documentElement === containerEl ? ctx.doc.head : containerEl; + style.setAttribute('q:style', styleTask.scope); + style.textContent = styleTask.content; + stylesParent.insertBefore(style, containerEl.firstChild); + } + }; + ctx.operations.push({ + el: hostElement, + operation: 'append-style', + args: [styleTask], + fn, + }); +} function prepend(ctx: RenderContext, parent: Element, newChild: Node) { const fn = () => { parent.insertBefore(newChild, parent.firstChild); diff --git a/src/core/render/jsx/types/jsx-qwik-attributes.ts b/src/core/render/jsx/types/jsx-qwik-attributes.ts index c03d33eddf6..e38e8ec6673 100644 --- a/src/core/render/jsx/types/jsx-qwik-attributes.ts +++ b/src/core/render/jsx/types/jsx-qwik-attributes.ts @@ -16,7 +16,6 @@ export interface QwikProps { /** * URL against which relative QRLs should be resolved to. */ - 'q:base'?: string; 'q:obj'?: string; 'q:host'?: string; 'q:version'?: string; diff --git a/src/core/render/notify-render.ts b/src/core/render/notify-render.ts index 2c65c1b43e2..11f1a065be4 100644 --- a/src/core/render/notify-render.ts +++ b/src/core/render/notify-render.ts @@ -7,6 +7,7 @@ import { getPlatform } from '../platform/platform'; import { getDocument } from '../util/dom'; import { renderComponent } from '../component/component-ctx'; import { logDebug } from '../util/log'; +import { getContainer } from '../use/use-core'; /** * Mark component for rendering. @@ -22,14 +23,15 @@ import { logDebug } from '../util/log'; * @returns A promise which is resolved when the component has been rendered. * @public */ -// TODO(misko): tests -// TODO(misko): this should take QComponent as well. export function notifyRender(hostElement: Element): Promise { assertDefined(hostElement.getAttribute(QHostAttr)); - const doc = getDocument(hostElement); - resumeIfNeeded(hostElement); + + const containerEl = getContainer(hostElement)!; + assertDefined(containerEl); + resumeIfNeeded(containerEl); + const ctx = getContext(hostElement); - const state = getRenderingState(doc); + const state = getRenderingState(containerEl); if (ctx.dirty) { // TODO return state.renderPromise!; @@ -48,13 +50,13 @@ export function notifyRender(hostElement: Element): Promise { }); } else { state.hostsNext.add(hostElement); - return scheduleFrame(doc, state); + return scheduleFrame(containerEl, state); } } -export function scheduleFrame(doc: Document, state: RenderingState): Promise { +export function scheduleFrame(containerEl: Element, state: RenderingState): Promise { if (state.renderPromise === undefined) { - state.renderPromise = getPlatform(doc).nextTick(() => renderMarked(doc, state)); + state.renderPromise = getPlatform(containerEl).nextTick(() => renderMarked(containerEl, state)); } return state.renderPromise; } @@ -68,10 +70,10 @@ export interface RenderingState { renderPromise: Promise | undefined; } -export function getRenderingState(doc: Document): RenderingState { - let set = (doc as any)[SCHEDULE] as RenderingState; +export function getRenderingState(containerEl: Element): RenderingState { + let set = (containerEl as any)[SCHEDULE] as RenderingState; if (!set) { - (doc as any)[SCHEDULE] = set = { + (containerEl as any)[SCHEDULE] = set = { hostsNext: new Set(), hostsStaging: new Set(), renderPromise: undefined, @@ -81,11 +83,15 @@ export function getRenderingState(doc: Document): RenderingState { return set; } -export async function renderMarked(doc: Document, state: RenderingState): Promise { +export async function renderMarked( + containerEl: Element, + state: RenderingState +): Promise { state.hostsRendering = new Set(state.hostsNext); state.hostsNext.clear(); - const platform = getPlatform(doc); + const doc = getDocument(containerEl); + const platform = getPlatform(containerEl); const renderingQueue = Array.from(state.hostsRendering); sortNodes(renderingQueue); @@ -95,6 +101,7 @@ export async function renderMarked(doc: Document, state: RenderingState): Promis hostElements: new Set(), operations: [], roots: [], + containerEl, component: undefined, perf: { visited: 0, @@ -117,7 +124,7 @@ export async function renderMarked(doc: Document, state: RenderingState): Promis printRenderStats(ctx); } } - postRendering(doc, state); + postRendering(containerEl, state); return ctx; } @@ -128,12 +135,12 @@ export async function renderMarked(doc: Document, state: RenderingState): Promis printRenderStats(ctx); } } - postRendering(doc, state); + postRendering(containerEl, state); return ctx; }); } -function postRendering(doc: Document, state: RenderingState) { +function postRendering(containerEl: Element, state: RenderingState) { // Move elements from staging to nextRender state.hostsStaging.forEach((el) => { state.hostsNext.add(el); @@ -145,7 +152,7 @@ function postRendering(doc: Document, state: RenderingState) { state.renderPromise = undefined; if (state.hostsNext.size > 0) { - scheduleFrame(doc, state); + scheduleFrame(containerEl, state); } } diff --git a/src/core/render/render.public.ts b/src/core/render/render.public.ts index 6868dab00f5..ae79b99de9f 100644 --- a/src/core/render/render.public.ts +++ b/src/core/render/render.public.ts @@ -10,6 +10,7 @@ import { getDocument } from '../util/dom'; import { qDev, qTest } from '../util/qdev'; import { resumeIfNeeded } from '../props/props'; import { version } from '../version'; +import { QContainerAttr } from '../util/markers'; /** * Render JSX. @@ -31,22 +32,24 @@ export function render( if (!isJSXNode(jsxNode)) { jsxNode = jsx(jsxNode, null); } - const doc = isDocument(parent) ? parent : getDocument(parent); - resumeIfNeeded(parent); + const doc = getDocument(parent); + const containerEl = getElement(parent); + resumeIfNeeded(containerEl); + injectQVersion(containerEl); const ctx: RenderContext = { doc, - globalState: getRenderingState(doc), + globalState: getRenderingState(containerEl), hostElements: new Set(), operations: [], roots: [parent as Element], component: undefined, + containerEl, perf: { visited: 0, timing: [], }, }; - injectQVersion(parent); return then(visitJsxNode(ctx, parent as Element, processNode(jsxNode), false), () => { executeContext(ctx); @@ -63,17 +66,20 @@ export function render( }); } -export function injectQwikSlotCSS(parent: Document | Element) { - const doc = isDocument(parent) ? parent : getDocument(parent); - const element = isDocument(parent) ? parent.head : parent; +export function injectQwikSlotCSS(docOrElm: Document | Element) { + const doc = getDocument(docOrElm); + const element = isDocument(docOrElm) ? docOrElm.head : docOrElm; const style = doc.createElement('style'); style.setAttribute('id', 'qwik/base-styles'); style.textContent = `q\\:slot{display:contents}q\\:fallback{display:none}q\\:fallback:last-child{display:contents}`; element.insertBefore(style, element.firstChild); } -export function injectQVersion(parent: Document | Element) { - const element = isDocument(parent) ? parent.documentElement : parent; - element.setAttribute('q:version', version || ''); - element.setAttribute('q:container', ''); +export function getElement(docOrElm: Document | Element): Element { + return isDocument(docOrElm) ? docOrElm.documentElement : docOrElm; +} + +export function injectQVersion(containerEl: Element) { + containerEl.setAttribute('q:version', version || ''); + containerEl.setAttribute(QContainerAttr, ''); } diff --git a/src/core/render/render.unit.tsx b/src/core/render/render.unit.tsx index 9c3988082bd..e19c45d4c83 100644 --- a/src/core/render/render.unit.tsx +++ b/src/core/render/render.unit.tsx @@ -67,8 +67,7 @@ describe('render', () => { text ); - expectDOM( - fixture.host.firstElementChild!, + expectRendered(
text
@@ -82,8 +81,7 @@ describe('render', () => { text ); - expectDOM( - fixture.host.firstElementChild!, + expectRendered( text @@ -143,7 +141,7 @@ describe('render', () => { ); - notifyRender(fixture.host.firstElementChild!); + notifyRender(getFirstNode(fixture.host)); await getTestPlatform(fixture.document).flush(); expectRendered(
@@ -302,7 +300,7 @@ describe('render', () => { ); - notifyRender(fixture.host.firstElementChild!); + notifyRender(getFirstNode(fixture.host)); await getTestPlatform(fixture.document).flush(); expectRendered( @@ -591,9 +589,18 @@ describe('render', () => { }); function expectRendered(expected: h.JSX.Element, expectedErrors: string[] = []) { - return expectDOM(fixture.host.firstElementChild!, expected, expectedErrors); + const firstNode = getFirstNode(fixture.host); + return expectDOM(firstNode, expected, expectedErrors); } }); + +function getFirstNode(el: Element) { + let firstNode = el.firstElementChild!; + while (firstNode.nodeName === 'STYLE') { + firstNode = firstNode.nextElementSibling!; + } + return firstNode; +} ////////////////////////////////////////////////////////////////////////////////////////// // Hello World ////////////////////////////////////////////////////////////////////////////////////////// diff --git a/src/core/use/use-core.ts b/src/core/use/use-core.ts index d1b447db5c4..0e0025ce003 100644 --- a/src/core/use/use-core.ts +++ b/src/core/use/use-core.ts @@ -3,11 +3,21 @@ import type { Props } from '../props/props.public'; import { assertDefined } from '../assert/assert'; import type { QwikDocument } from '../document'; import type { QRLInternal } from '../import/qrl-class'; -import { QHostAttr } from '../util/markers'; +import { QContainerSelector, QHostAttr } from '../util/markers'; import { getDocument } from '../util/dom'; declare const document: QwikDocument; +export interface StyleAppend { + type: 'style'; + scope: string; + content: string; +} + +export function isStyleTask(obj: any): obj is StyleAppend { + return obj && typeof obj === 'object' && obj.type === 'style'; +} + export interface InvokeContext { doc?: Document; hostElement?: Element; @@ -119,3 +129,7 @@ export function getHostElement(el: Element): Element | null { } return node; } + +export function getContainer(el: Element): Element | null { + return el.closest(QContainerSelector); +} diff --git a/src/core/use/use-lexical-scope.public.ts b/src/core/use/use-lexical-scope.public.ts index 4c22262e3df..05650108ba5 100644 --- a/src/core/use/use-lexical-scope.public.ts +++ b/src/core/use/use-lexical-scope.public.ts @@ -3,7 +3,7 @@ import { assertDefined } from '../assert/assert'; import { parseQRL } from '../import/qrl'; import { qInflate } from '../json/q-json'; import { getContext, resumeIfNeeded } from '../props/props'; -import { getInvokeContext } from './use-core'; +import { getContainer, getInvokeContext } from './use-core'; import { useURL } from './use-url.public'; // @@ -27,7 +27,7 @@ export function useLexicalScope(): VARS { if (qrl.captureRef == null) { const el = context.element!; assertDefined(el); - resumeIfNeeded(el); + resumeIfNeeded(getContainer(el)!); const ctx = getContext(el); qrl.captureRef = qrl.capture!.map((idx) => qInflate(idx, ctx)); } diff --git a/src/core/util/dom.ts b/src/core/util/dom.ts index e091961d2f2..6e982370365 100644 --- a/src/core/util/dom.ts +++ b/src/core/util/dom.ts @@ -12,6 +12,9 @@ export function getDocument(node: Node): Document { if (typeof document !== 'undefined') { return document; } + if (node.nodeType === 9) { + return node as Document; + } let doc = node.ownerDocument; while (doc && doc.nodeType !== 9) { doc = doc.parentNode as any; diff --git a/src/core/util/markers.ts b/src/core/util/markers.ts index adae56a8b36..e6bfba42c0d 100644 --- a/src/core/util/markers.ts +++ b/src/core/util/markers.ts @@ -45,12 +45,12 @@ export const ComponentUnscopedStyles = 'q:ustyle'; /** * Component style host prefix */ -export const ComponentStylesPrefixHost = '📦'; +export const ComponentStylesPrefixHost = '💎'; /** * Component style content prefix */ -export const ComponentStylesPrefixContent = '🏷️'; +export const ComponentStylesPrefixContent = '⭐️'; /** * Prefix used to identify on listeners. @@ -75,8 +75,12 @@ export const QSlotAttr = 'q:slot'; export const QObjAttr = 'q:obj'; +export const QContainerAttr = 'q:container'; + export const QObjSelector = '[q\\:obj]'; +export const QContainerSelector = '[q\\:container]'; + /** * `` */ diff --git a/src/server/api.md b/src/server/api.md index f13d0049eca..6b58f508489 100644 --- a/src/server/api.md +++ b/src/server/api.md @@ -71,21 +71,24 @@ export const QwikLoader: FunctionComponent; export const QwikPrefetch: FunctionComponent; // @public -export function renderToDocument(doc: Document, rootNode: JSXNode | FunctionComponent, opts: RenderToDocumentOptions): Promise; +export function renderToDocument(docOrElm: Document | Element, rootNode: JSXNode | FunctionComponent, opts: RenderToDocumentOptions): Promise; // Warning: (ae-forgotten-export) The symbol "SerializeDocumentOptions" needs to be exported by the entry point index.d.ts // // @public (undocumented) export interface RenderToDocumentOptions extends SerializeDocumentOptions, DocumentOptions { + base?: string; fragmentTagName?: string; snapshot?: boolean; } // @public -export function renderToString(rootNode: any, opts: RenderToStringOptions): Promise; +export function renderToString(rootNode: JSXNode, opts: RenderToStringOptions): Promise; // @public (undocumented) export interface RenderToStringOptions extends RenderToDocumentOptions { + // (undocumented) + fragmentTagName?: string; } // @public (undocumented) @@ -101,7 +104,7 @@ export interface RenderToStringResult { } // @public -export function serializeDocument(doc: Document, opts?: SerializeDocumentOptions): string; +export function serializeDocument(docOrEl: Document | Element, opts?: SerializeDocumentOptions): string; // @public export function setServerPlatform(document: any, opts: SerializeDocumentOptions): Promise; diff --git a/src/server/document.ts b/src/server/document.ts index 81b9a30c6c3..98eb4472e4f 100644 --- a/src/server/document.ts +++ b/src/server/document.ts @@ -12,6 +12,9 @@ import type { RenderToStringOptions, RenderToStringResult, } from './types'; +import { isDocument } from '../core/util/element'; +import { getDocument } from '../core/util/dom'; +import { getElement } from '../core/render/render.public'; /** * Create emulated `Global` for server environment. Does not implement a browser @@ -46,18 +49,23 @@ export function createDocument(opts?: DocumentOptions) { * @public */ export async function renderToDocument( - doc: Document, + docOrElm: Document | Element, rootNode: JSXNode | FunctionComponent, opts: RenderToDocumentOptions ) { + const doc = isDocument(docOrElm) ? docOrElm : getDocument(docOrElm); ensureGlobals(doc, opts); await setServerPlatform(doc, opts); - await render(doc, rootNode); + await render(docOrElm, rootNode); + if (opts.base) { + const containerEl = getElement(docOrElm); + containerEl.setAttribute('q:base', opts.base); + } if (opts.snapshot !== false) { - snapshot(doc); + snapshot(docOrElm); } } @@ -66,18 +74,23 @@ export async function renderToDocument( * then serializes the document to a string. * @public */ -export async function renderToString(rootNode: any, opts: RenderToStringOptions) { +export async function renderToString(rootNode: JSXNode, opts: RenderToStringOptions) { const createDocTimer = createTimer(); const doc = createDocument(opts); const createDocTime = createDocTimer(); const renderDocTimer = createTimer(); - await renderToDocument(doc, rootNode, opts); + let rootEl: Element | Document = doc; + if (typeof opts.fragmentTagName === 'string') { + rootEl = doc.createElement(opts.fragmentTagName); + doc.body.appendChild(rootEl); + } + await renderToDocument(rootEl, rootNode, opts); const renderDocTime = renderDocTimer(); const docToStringTimer = createTimer(); const result: RenderToStringResult = { - html: serializeDocument(doc, opts), + html: serializeDocument(rootEl, opts), timing: { createDocument: createDocTime, render: renderDocTime, diff --git a/src/server/serialize.ts b/src/server/serialize.ts index 804ca1353a0..064006d55b7 100644 --- a/src/server/serialize.ts +++ b/src/server/serialize.ts @@ -1,3 +1,4 @@ +import { isDocument } from '../core/util/element'; import type { SerializeDocumentOptions } from './types'; /** @@ -8,16 +9,16 @@ import type { SerializeDocumentOptions } from './types'; * @param rootNode - The root JSX node to apply onto the `document`. * @public */ -export function serializeDocument(doc: Document, opts?: SerializeDocumentOptions) { - if (!doc || doc.nodeType !== 9) { - throw new Error(`Invalid document to serialize`); +export function serializeDocument(docOrEl: Document | Element, opts?: SerializeDocumentOptions) { + if (!isDocument(docOrEl)) { + // TODO: move head styles + return docOrEl.outerHTML; } - const symbols = opts?.symbols; if (typeof symbols === 'object' && symbols != null) { if (symbols.injections) { for (const injection of symbols.injections) { - const el = doc.createElement(injection.tag); + const el = docOrEl.createElement(injection.tag); if (injection.attributes) { Object.entries(injection.attributes).forEach(([attr, value]) => { el.setAttribute(attr, value); @@ -26,11 +27,10 @@ export function serializeDocument(doc: Document, opts?: SerializeDocumentOptions if (injection.children) { el.textContent = injection.children; } - const parent = injection.location === 'head' ? doc.head : doc.body; + const parent = injection.location === 'head' ? docOrEl.head : docOrEl.body; parent.appendChild(el); } } } - - return '' + doc.documentElement.outerHTML; + return '' + docOrEl.documentElement.outerHTML; } diff --git a/src/server/types.ts b/src/server/types.ts index 65ea9ba3315..daa6ddbbf8e 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -66,7 +66,13 @@ export interface RenderToDocumentOptions extends SerializeDocumentOptions, Docum snapshot?: boolean; /** - * When set, + * Specifies the root of the JS files of the client build. + * Setting a base, will cause the render of the `q:base` attribute in the `q:container` element. + */ + base?: string; + + /** + * When set, the app is serialized into a fragment. And the returned html is not a complete document. * Defaults to `undefined` */ fragmentTagName?: string; @@ -75,7 +81,9 @@ export interface RenderToDocumentOptions extends SerializeDocumentOptions, Docum /** * @public */ -export interface RenderToStringOptions extends RenderToDocumentOptions {} +export interface RenderToStringOptions extends RenderToDocumentOptions { + fragmentTagName?: string; +} /** * @public diff --git a/src/testing/api.md b/src/testing/api.md index f13d0049eca..6b58f508489 100644 --- a/src/testing/api.md +++ b/src/testing/api.md @@ -71,21 +71,24 @@ export const QwikLoader: FunctionComponent; export const QwikPrefetch: FunctionComponent; // @public -export function renderToDocument(doc: Document, rootNode: JSXNode | FunctionComponent, opts: RenderToDocumentOptions): Promise; +export function renderToDocument(docOrElm: Document | Element, rootNode: JSXNode | FunctionComponent, opts: RenderToDocumentOptions): Promise; // Warning: (ae-forgotten-export) The symbol "SerializeDocumentOptions" needs to be exported by the entry point index.d.ts // // @public (undocumented) export interface RenderToDocumentOptions extends SerializeDocumentOptions, DocumentOptions { + base?: string; fragmentTagName?: string; snapshot?: boolean; } // @public -export function renderToString(rootNode: any, opts: RenderToStringOptions): Promise; +export function renderToString(rootNode: JSXNode, opts: RenderToStringOptions): Promise; // @public (undocumented) export interface RenderToStringOptions extends RenderToDocumentOptions { + // (undocumented) + fragmentTagName?: string; } // @public (undocumented) @@ -101,7 +104,7 @@ export interface RenderToStringResult { } // @public -export function serializeDocument(doc: Document, opts?: SerializeDocumentOptions): string; +export function serializeDocument(docOrEl: Document | Element, opts?: SerializeDocumentOptions): string; // @public export function setServerPlatform(document: any, opts: SerializeDocumentOptions): Promise; diff --git a/src/testing/expect-dom.unit.tsx b/src/testing/expect-dom.unit.tsx index c7b02607b44..8c8c1fe89f1 100644 --- a/src/testing/expect-dom.unit.tsx +++ b/src/testing/expect-dom.unit.tsx @@ -18,7 +18,8 @@ function expectMatchElement( path: string, diffs: string[], actual: Element, - expected: QwikJSX.Element + expected: QwikJSX.Element, + keepStyles = false ) { if (actual) { const actualTag = actual.localName ? actual.localName : '#text'; @@ -38,9 +39,14 @@ function expectMatchElement( }); } - const actualChildNodes = isTemplateElement(actual) - ? actual.content.childNodes - : actual.childNodes; + let actualChildNodes = Array.from( + isTemplateElement(actual) ? actual.content.childNodes : actual.childNodes + ); + + if (!keepStyles) { + actualChildNodes = actualChildNodes.filter((el) => el.nodeName !== 'STYLE'); + } + (expected.children || []).forEach((expectedChild, index) => { const actualChild = actualChildNodes[index]; if (expectedChild.text === undefined) { diff --git a/src/testing/platform.ts b/src/testing/platform.ts index c7493aa9cee..949c7cf460e 100644 --- a/src/testing/platform.ts +++ b/src/testing/platform.ts @@ -2,6 +2,7 @@ import { getPlatform, setPlatform } from '@builder.io/qwik'; import type { TestPlatform } from './types'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; +import { getContainer } from '../core/use/use-core'; function createPlatform(document: any) { if (!document || (document as Document).nodeType !== 9) { @@ -94,27 +95,10 @@ export function setTestPlatform(document: any) { * @param url - relative URL * @returns fully qualified URL. */ -export function toUrl(doc: Document, element: Element | null, url?: string | URL): URL { - let _url: string | URL; - let _base: string | URL | undefined = undefined; - - if (url === undefined) { - // recursive call - if (element) { - _url = element.getAttribute('q:base')!; - _base = toUrl( - doc, - element.parentNode && (element.parentNode as HTMLElement).closest('[q\\:base]') - ); - } else { - _url = doc.baseURI; - } - } else if (url) { - (_url = url), (_base = toUrl(doc, element!.closest('[q\\:base]'))); - } else { - throw new Error('INTERNAL ERROR'); - } - return new URL(String(_url), _base); +export function toUrl(doc: Document, element: Element, url: string | URL): URL { + const containerEl = getContainer(element); + const base = new URL(containerEl?.getAttribute('q:base') ?? doc.baseURI, doc.baseURI); + return new URL(url, base); } function toPath(url: URL) { diff --git a/starters/apps/e2e/src/entry.server.tsx b/starters/apps/e2e/src/entry.server.tsx index 99f5fa29a93..d285b7381a0 100644 --- a/starters/apps/e2e/src/entry.server.tsx +++ b/starters/apps/e2e/src/entry.server.tsx @@ -40,11 +40,14 @@ export function render(opts: RenderToStringOptions) { Qwik Blank App - + , - opts + { + ...opts, + base: '/', + } ); } diff --git a/starters/apps/starter-builder/src/entry.server.tsx b/starters/apps/starter-builder/src/entry.server.tsx index 48b151f0484..41e5592006d 100644 --- a/starters/apps/starter-builder/src/entry.server.tsx +++ b/starters/apps/starter-builder/src/entry.server.tsx @@ -21,13 +21,16 @@ export function render(opts: RenderToStringOptions) { Qwik Blank App - +
, - opts + { + ...opts, + base: '/', + } ); } diff --git a/starters/apps/starter-partytown/src/entry.server.tsx b/starters/apps/starter-partytown/src/entry.server.tsx index 3df94f904c7..938802b51f9 100644 --- a/starters/apps/starter-partytown/src/entry.server.tsx +++ b/starters/apps/starter-partytown/src/entry.server.tsx @@ -23,7 +23,7 @@ export function render(opts: RenderToStringOptions) { Qwik + Partytown Blank App