diff --git a/src/bootloader-shared.ts b/src/bootloader-shared.ts index 2949fa565a4..a53dc68356c 100644 --- a/src/bootloader-shared.ts +++ b/src/bootloader-shared.ts @@ -71,7 +71,7 @@ export const qwikLoader = (doc: Document, hasInitialized?: boolean | number) => if (url) { const handler = getModuleExport( url, - (window as any)[url.pathname] || (await import(String(url))) + (window as any)[url.pathname] || (await import(String(url).split('#')[0])) ); handler(element, ev, url); } diff --git a/src/core/api.md b/src/core/api.md index 4abdbc8eaab..33911fa4b7c 100644 --- a/src/core/api.md +++ b/src/core/api.md @@ -175,16 +175,16 @@ export interface QComponent exte styleClass: string | null; // (undocumented) styleHostClass: string | null; - // Warning: (ae-forgotten-export) The symbol "QrlStyles" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "QStyles" needs to be exported by the entry point index.d.ts // // (undocumented) - styles: QrlStyles | null; + styles: QStyles | null; // (undocumented) tag: string; } // @public -export function qComponent({ onRender, styles, tagName, props, onResume, onMount, onUnmount, onHydrate, onDehydrate, }: { +export function qComponent({ onRender, styles, unscopedStyles, tagName, props, onResume, onMount, onUnmount, onHydrate, onDehydrate, }: { onRender: QHook; tagName?: string; onMount?: QHook; @@ -192,7 +192,8 @@ export function qComponent({ onRender, styles, tagName, onDehydrate?: QHook | null; onHydrate?: QHook | null; onResume?: QHook | null; - styles?: QrlStyles; + styles?: QStyles; + unscopedStyles?: QStyles; props?: PROPS; }): QComponent; diff --git a/src/core/component/q-component-ctx.ts b/src/core/component/q-component-ctx.ts index ab38ac4a2ec..c1cfe0c3f64 100644 --- a/src/core/component/q-component-ctx.ts +++ b/src/core/component/q-component-ctx.ts @@ -1,12 +1,13 @@ import { assertDefined } from '../assert/assert'; -import { cursorForComponent, cursorReconcileEnd } from '../render/cursor'; +import { qImport } from '../import/qImport'; +import { _stateQObject } from '../object/q-object'; import type { OnHookReturn } from '../props/q-props'; +import { qProps } from '../props/q-props.public'; +import { cursorForComponent, cursorReconcileEnd } from '../render/cursor'; import { ComponentRenderQueue, visitJsxNode } from '../render/q-render'; import { AttributeMarker } from '../util/markers'; import { flattenPromiseTree } from '../util/promises'; -import { QrlStyles, styleContent, styleHost } from './qrl-styles'; -import { _stateQObject } from '../object/q-object'; -import { qProps } from '../props/q-props.public'; +import { styleContent, styleHost, styleKey } from './qrl-styles'; // TODO(misko): Can we get rid of this whole file, and instead teach qProps to know how to render // the advantage will be that the render capability would then be exposed to the outside world as well. @@ -21,10 +22,12 @@ export class QComponentCtx { constructor(hostElement: HTMLElement) { this.hostElement = hostElement; - const styleId = (this.styleId = hostElement.getAttribute(AttributeMarker.ComponentStyles)); - if (styleId) { - this.styleHostClass = styleHost(styleId as any as QrlStyles); - this.styleClass = styleContent(styleId as any as QrlStyles); + const scopedStyleId = (this.styleId = styleKey( + hostElement.getAttribute(AttributeMarker.ComponentStyles) as any + )!); + if (scopedStyleId) { + this.styleHostClass = styleHost(scopedStyleId); + this.styleClass = styleContent(scopedStyleId); } } @@ -37,6 +40,10 @@ export class QComponentCtx { // TODO(misko): extract constant if (props['state:'] == null) { try { + const scopedStyle: string | null = props[AttributeMarker.ComponentStyles]; + const unscopedStyle: string | null = props[AttributeMarker.ComponentUnscopedStyles]; + insertStyleIfNeeded(this, scopedStyle); + insertStyleIfNeeded(this, unscopedStyle); const hook = props['on:qMount']; if (hook) { const values: OnHookReturn[] = await hook('qMount'); @@ -101,3 +108,20 @@ export function getHostElement(element: Element): HTMLElement | null { } return element as HTMLElement | null; } + +function insertStyleIfNeeded(ctx: QComponentCtx, style: string | null) { + if (style) { + const styleId = styleKey(style as any)!; + const document = ctx.hostElement.ownerDocument; + const head = document.querySelector('head')!; + if (!head.querySelector(`style[q\\:style="${styleId}"]`)) { + const styleImport = Promise.resolve(qImport(document, style)); + styleImport.then((styles: string) => { + const style = document.createElement('style'); + style.setAttribute('q:style', styleId); + style.textContent = styles.replace(/�/g, styleId); + head.appendChild(style); + }); + } + } +} diff --git a/src/core/component/q-component.public.ts b/src/core/component/q-component.public.ts index dd094cf936a..8f1d2489553 100644 --- a/src/core/component/q-component.public.ts +++ b/src/core/component/q-component.public.ts @@ -3,7 +3,7 @@ import type { HTMLAttributes } from '../render/jsx/types/jsx-generated'; import type { FunctionComponent, JSXNode } from '../render/jsx/types/jsx-node'; import { AttributeMarker } from '../util/markers'; import type { QHook } from './qrl-hook.public'; -import { QrlStyles, styleContent, styleHost } from './qrl-styles'; +import { QStyles, styleContent, styleHost, styleKey } from './qrl-styles'; // // !!DO NOT EDIT THIS COMMENT DIRECTLY!!! (edit ./q-component.md instead) @@ -69,6 +69,7 @@ import { QrlStyles, styleContent, styleHost } from './qrl-styles'; export function qComponent({ onRender, styles, + unscopedStyles, tagName, props, onResume, @@ -204,7 +205,8 @@ export function qComponent({ */ // // TODO(misko): finish documentation once implemented. - styles?: QrlStyles; + styles?: QStyles; + unscopedStyles?: QStyles; // // !!DO NOT EDIT THIS COMMENT DIRECTLY!!! (edit ./q-component.md instead) @@ -249,6 +251,7 @@ export function qComponent({ [AttributeMarker.OnHydrate]: onHydrate, [AttributeMarker.OnDehydrate]: onDehydrate, [AttributeMarker.ComponentStyles]: styles, + [AttributeMarker.ComponentUnscopedStyles]: unscopedStyles, ...props, ...jsxProps, }); @@ -261,8 +264,9 @@ export function qComponent({ QComponent.onHydrate = onHydrate || null; QComponent.onDehydrate = onDehydrate || null; QComponent.styles = styles || null; - QComponent.styleHostClass = styleHost(styles) || null; - QComponent.styleClass = styleContent(styles) || null; + const styleId = styleKey(styles); + QComponent.styleHostClass = styleHost(styleId) || null; + QComponent.styleClass = styleContent(styleId) || null; return QComponent; } @@ -333,7 +337,7 @@ export interface QComponent onUnmount: QHook | null; onDehydrate: QHook | null; onHydrate: QHook | null; - styles: QrlStyles | null; + styles: QStyles | null; styleClass: string | null; styleHostClass: string | null; props: Record; diff --git a/src/core/component/q-component.unit.tsx b/src/core/component/q-component.unit.tsx index 769c4b1c9e9..44316d09910 100644 --- a/src/core/component/q-component.unit.tsx +++ b/src/core/component/q-component.unit.tsx @@ -1,10 +1,10 @@ import { Fragment, h, qHook, qObject } from '@builder.io/qwik'; import { ElementFixture, trigger } from '../../testing/element_fixture'; -import { expectDOM } from '../../testing/expect-dom'; +import { expectDOM } from '../../testing/expect-dom.unit'; import { qRender } from '../render/q-render.public'; import { TEST_CONFIG } from '../util/test_config'; import { qComponent } from './q-component.public'; -import { qrlStyles } from './qrl-styles'; +import { qStyles } from './qrl-styles'; describe('q-component', () => { it('should declare and render basic component', async () => { @@ -90,7 +90,7 @@ export const HelloWorld = qComponent({ onRender: qHook(() => { return Hello World; }), - styles: qrlStyles('./mock.unit.css#ABC123'), + styles: qStyles('./mock.unit.css#ABC123'), }); ///////////////////////////////////////////////////////////////////////////// diff --git a/src/core/component/qrl-styles.ts b/src/core/component/qrl-styles.ts index f0dd2a437d6..20368a8eb40 100644 --- a/src/core/component/qrl-styles.ts +++ b/src/core/component/qrl-styles.ts @@ -2,12 +2,13 @@ import type { QRL } from '../import/qrl'; import { toDevModeQRL } from '../import/qrl-test'; import { AttributeMarker } from '../util/markers'; import { qTest } from '../util/qdev'; +import { hashCode } from '../util/hash_code'; import type { QComponent } from './q-component.public'; /** * @public */ -export interface QrlStyles extends QRL { +export interface QStyles extends QRL { __brand__qrl__styles__: 'QrlStyles'; __types__: COMP; } @@ -15,35 +16,34 @@ export interface QrlStyles extends QRL { /** * @public */ -// TODO(misko): Rename to qrlComponentStyles to be consistent with qrlOnRender???? -export function qrlStyles(styles: string): QrlStyles { +export function qStyles(styles: string): QStyles { if (qTest) { return String(toDevModeQRL(styles, new Error())) as any; } - return styles as unknown as QrlStyles; + return styles as unknown as QStyles; } /** * @public */ -export function styleKey(qrl: QrlStyles | undefined): string | undefined { - return qrl && String(qrl).split('#')[1]; +export function styleKey(qStyles: QStyles | undefined): string | undefined { + return qStyles && String(hashCode(String(qStyles))); } /** * @public */ -export function styleHost(qrl: QrlStyles): string; -export function styleHost(qrl: QrlStyles | undefined): string | undefined; -export function styleHost(qrl: QrlStyles | undefined): string | undefined { - return qrl && AttributeMarker.ComponentStylesPrefixHost + styleKey(qrl); +export function styleHost(styleId: string): string; +export function styleHost(styleId: string | undefined): string | undefined; +export function styleHost(styleId: string | undefined): string | undefined { + return styleId && AttributeMarker.ComponentStylesPrefixHost + styleId; } /** * @public */ -export function styleContent(qrl: QrlStyles): string; -export function styleContent(qrl: QrlStyles | undefined): string | undefined; -export function styleContent(qrl: QrlStyles | undefined): string | undefined { - return qrl && AttributeMarker.ComponentStylesPrefixContent + styleKey(qrl); +export function styleContent(styleId: string): string; +export function styleContent(styleId: string | undefined): string | undefined; +export function styleContent(styleId: string | undefined): string | undefined { + return styleId && AttributeMarker.ComponentStylesPrefixContent + styleId; } diff --git a/src/core/render/cursor.unit.tsx b/src/core/render/cursor.unit.tsx index d9d9149b448..0deac3b5a04 100644 --- a/src/core/render/cursor.unit.tsx +++ b/src/core/render/cursor.unit.tsx @@ -1,5 +1,5 @@ import { Fragment, h, Slot } from '@builder.io/qwik'; -import { expectDOM } from '../../testing/expect-dom'; +import { expectDOM } from '../../testing/expect-dom.unit'; import { toDOM } from '../../testing/jsx'; import { qHook } from '../component/qrl-hook.public'; import { AttributeMarker } from '../util/markers'; diff --git a/src/core/render/jsx/types/jsx-qwik-attributes.ts b/src/core/render/jsx/types/jsx-qwik-attributes.ts index da0af7721e1..41d88b3103f 100644 --- a/src/core/render/jsx/types/jsx-qwik-attributes.ts +++ b/src/core/render/jsx/types/jsx-qwik-attributes.ts @@ -119,4 +119,6 @@ interface QwikDOMEvents { 'on:wheel'?: QRL; } -export interface DOMAttributes extends QwikProps, QwikGlobalEvents, QwikDOMEvents {} +export interface DOMAttributes extends QwikProps, QwikGlobalEvents, QwikDOMEvents { + children?: any; +} diff --git a/src/core/render/q-render.unit.tsx b/src/core/render/q-render.unit.tsx index 2a1adb83c5a..c0696fcc9cf 100644 --- a/src/core/render/q-render.unit.tsx +++ b/src/core/render/q-render.unit.tsx @@ -1,9 +1,9 @@ import { Fragment, h, Host } from '@builder.io/qwik'; import { ElementFixture, trigger } from '../../testing/element_fixture'; -import { expectDOM } from '../../testing/expect-dom'; +import { expectDOM } from '../../testing/expect-dom.unit'; import { qComponent } from '../component/q-component.public'; import { qHook } from '../component/qrl-hook.public'; -import { qrlStyles } from '../component/qrl-styles'; +import { qStyles, styleKey } from '../component/qrl-styles'; import { TEST_CONFIG } from '../util/test_config'; import { Async, JSXPromise, PromiseValue } from './jsx/async.public'; import { Slot } from './jsx/slot.public'; @@ -304,7 +304,7 @@ describe('q-render', () => { expectRendered( @@ -312,6 +312,10 @@ describe('q-render', () => { ); + const style = fixture.document.querySelector( + 'style[q\\:style="' + styleKey(HelloWorld_styles) + '"]' + )!; + expect(style.textContent).toEqual('span { color: red; }'); }); }); }); @@ -323,9 +327,10 @@ describe('q-render', () => { ////////////////////////////////////////////////////////////////////////////////////////// // Hello World ////////////////////////////////////////////////////////////////////////////////////////// +const HelloWorld_styles = qStyles(`span { color: red; }`); export const HelloWorld = qComponent<{ name?: string }, { salutation: string }>({ tagName: 'hello-world', - styles: qrlStyles('./mock.unit.css#ABC123'), + styles: HelloWorld_styles, onMount: qHook(() => ({ salutation: 'Hello' })), onRender: qHook((props, state) => { return ( diff --git a/src/core/util/hash_code.ts b/src/core/util/hash_code.ts new file mode 100644 index 00000000000..b1dea9d9f71 --- /dev/null +++ b/src/core/util/hash_code.ts @@ -0,0 +1,10 @@ + +export function hashCode(text: string, hash: number = 0) { + if (text.length === 0) return hash; + for (let i = 0; i < text.length; i++) { + const chr = text.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // Convert to 32bit integer + } + return Number(Math.abs(hash)).toString(36); +} diff --git a/src/core/util/markers.ts b/src/core/util/markers.ts index 61342858480..8089d6e6324 100644 --- a/src/core/util/markers.ts +++ b/src/core/util/markers.ts @@ -40,7 +40,12 @@ export const enum AttributeMarker { /** * Component Styles. */ - ComponentStyles = 'q:style', + ComponentStyles = 'q:sstyle', + + /** + * Unscoped Component Styles. + */ + ComponentUnscopedStyles = 'q:ustyle', /** * Component style host prefix diff --git a/src/testing/expect-dom.ts b/src/testing/expect-dom.ts deleted file mode 100644 index 56397205f21..00000000000 --- a/src/testing/expect-dom.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { h } from '@builder.io/qwik'; -import { isJSXNode } from '../core/render/jsx/jsx-runtime'; -import { isComment, isElement, isText } from '../core/util/element'; -import { isTemplateElement } from '../core/util/types'; - -export function expectDOM(actual: Element, expected: h.JSX.Element, expectedErrors: string[] = []) { - const diffs: string[] = []; - expectMatchElement('', diffs, actual, expected); - expect(diffs).toEqual(expectedErrors); -} - -function expectMatchElement( - path: string, - diffs: string[], - actual: Element, - expected: h.JSX.Element -) { - if (actual) { - const actualTag = actual.tagName ? actual.tagName.toLowerCase() : '#text'; - path += actualTag; - if (actualTag !== expected.type) { - diffs.push(`${path}: expected '${toHTML(expected)}', was '${toHTML(actual)}'.`); - } - Object.keys(expected.props).forEach((key) => { - if (key !== 'children') { - const expectedValue = expected.props[key] as any; - const actualValue = actual.getAttribute ? actual.getAttribute(key) : ''; - if (!(actualValue == expectedValue || (expectedValue === true && actualValue !== null))) { - diffs.push(`${path}: expected '${toHTML(expected)}', was '${toHTML(actual)}'.`); - } - } - }); - - const actualChildNodes = isTemplateElement(actual) - ? actual.content.childNodes - : actual.childNodes; - (expected.children || []).forEach((expectedChild, index) => { - const actualChild = actualChildNodes[index]; - if (isJSXNode(expectedChild)) { - expectMatchElement( - path + `.[${index}]`, - diffs, - actualChild as HTMLElement, - expectedChild as any - ); - } else { - // We are a text node. - const text = actualChild?.textContent || ''; - if (!(expectedChild instanceof RegExp ? expectedChild.test(text) : expectedChild == text)) { - diffs.push( - `${path}: expected content "${expectedChild}", was "${ - (actualChild as HTMLElement)?.outerHTML || actualChild?.textContent - }"` - ); - } - } - }); - for (let i = expected.children.length; i < actualChildNodes.length; i++) { - const childNode = actualChildNodes[i]; - diffs.push(`${path}[${i}]: extra node '${toHTML(childNode)}'`); - } - } else { - diffs.push(`${path}: expected '${toHTML(expected)}', was no children`); - } -} - -function toAttrs(jsxNode: h.JSX.Element): string[] { - const attrs: string[] = []; - Object.keys(jsxNode.props || {}).forEach((key) => { - if (key !== 'children') { - attrs.push(key + '=' + JSON.stringify(jsxNode.props[key])); - } - }); - return attrs; -} - -function toHTML(node: any) { - if (isElement(node)) { - const attrs: string[] = []; - const attributes = node.attributes; - for (let i = 0; i < attributes.length; i++) { - attrs.push(`${attributes[i].name}="${attributes[i].value}"`); - } - return `<${node.tagName.toLowerCase()}${attrs.length ? ' ' + attrs.join(' ') : ''}>`; - } else if (isText(node)) { - return node.textContent; - } else if (isJSXNode(node)) { - const attrs = toAttrs(node); - return `<${node.type}${attrs.length ? ' ' + attrs.join(' ') : ''}>`; - } else if (isComment(node)) { - return ``; - } else { - throw new Error('Unexpected node type: ' + node); - } -} diff --git a/src/testing/expect-dom.unit.tsx b/src/testing/expect-dom.unit.tsx index 53ac049c56b..228101d466a 100644 --- a/src/testing/expect-dom.unit.tsx +++ b/src/testing/expect-dom.unit.tsx @@ -1,6 +1,102 @@ import { h } from '@builder.io/qwik'; import domino from 'domino'; -import { expectDOM } from './expect-dom'; +import { isJSXNode } from '../core/render/jsx/jsx-runtime'; +import { isComment, isElement, isText } from '../core/util/element'; +import { isTemplateElement } from '../core/util/types'; + +export function expectDOM(actual: Element, expected: h.JSX.Element, expectedErrors: string[] = []) { + const diffs: string[] = []; + expectMatchElement('', diffs, actual, expected); + expect(diffs).toEqual(expectedErrors); +} + +function expectMatchElement( + path: string, + diffs: string[], + actual: Element, + expected: h.JSX.Element +) { + if (actual) { + const actualTag = actual.tagName ? actual.tagName.toLowerCase() : '#text'; + path += actualTag; + if (actualTag !== expected.type) { + diffs.push(`${path}: expected '${toHTML(expected)}', was '${toHTML(actual)}'.`); + } + Object.keys(expected.props).forEach((key) => { + if (key !== 'children') { + const expectedValue = expected.props[key] as any; + const actualValue = actual.getAttribute ? actual.getAttribute(key) : ''; + if (!(actualValue == expectedValue || (expectedValue === true && actualValue !== null))) { + diffs.push(`${path}: expected '${toHTML(expected)}', was '${toHTML(actual)}'.`); + } + } + }); + + const actualChildNodes = isTemplateElement(actual) + ? actual.content.childNodes + : actual.childNodes; + (expected.children || []).forEach((expectedChild, index) => { + const actualChild = actualChildNodes[index]; + if (isJSXNode(expectedChild)) { + expectMatchElement( + path + `.[${index}]`, + diffs, + actualChild as HTMLElement, + expectedChild as any + ); + } else { + // We are a text node. + const text = actualChild?.textContent || ''; + if (!(expectedChild instanceof RegExp ? expectedChild.test(text) : expectedChild == text)) { + diffs.push( + `${path}: expected content "${expectedChild}", was "${ + (actualChild as HTMLElement)?.outerHTML || actualChild?.textContent + }"` + ); + } + } + }); + for (let i = expected.children.length; i < actualChildNodes.length; i++) { + const childNode = actualChildNodes[i]; + diffs.push(`${path}[${i}]: extra node '${toHTML(childNode)}'`); + } + } else { + diffs.push(`${path}: expected '${toHTML(expected)}', was no children`); + } +} + +function toAttrs(jsxNode: h.JSX.Element): string[] { + const attrs: string[] = []; + Object.keys(jsxNode.props || {}).forEach((key) => { + if (key !== 'children') { + attrs.push(key + '=' + JSON.stringify(jsxNode.props[key])); + } + }); + return attrs; +} + +function toHTML(node: any) { + if (isElement(node)) { + const attrs: string[] = []; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + attrs.push(`${attributes[i].name}="${attributes[i].value}"`); + } + return `<${node.tagName.toLowerCase()}${attrs.length ? ' ' + attrs.join(' ') : ''}>`; + } else if (isText(node)) { + return node.textContent; + } else if (isJSXNode(node)) { + const attrs = toAttrs(node); + return `<${node.type}${attrs.length ? ' ' + attrs.join(' ') : ''}>`; + } else if (isComment(node)) { + return ``; + } else { + throw new Error('Unexpected node type: ' + node); + } +} + + + describe('expect-dom', () => { it('should match element', () => {