diff --git a/packages/qwik/src/core/client/diffJsx-utils.ts b/packages/qwik/src/core/client/diffJsx-utils.ts new file mode 100644 index 00000000000..8c2f2dee9b2 --- /dev/null +++ b/packages/qwik/src/core/client/diffJsx-utils.ts @@ -0,0 +1,41 @@ +import type { JSXNode } from '@builder.io/qwik'; +import { Fragment, Slot } from '@builder.io/qwik'; + +export function tagToString(tag: any): string { + return tag === Fragment ? 'Fragment' : tag === Slot ? 'Slot' : String(tag); +} + +export function attrsEqual(expectedValue: any, receivedValue: any) { + const isEqual = + typeof expectedValue == 'boolean' + ? expectedValue + ? receivedValue !== null + : receivedValue === null || receivedValue === 'false' + : expectedValue == receivedValue; + // console.log('attrsEqual', expectedValue, receivedValue, isEqual); + return isEqual; +} + +export function getJSXChildren(jsx: JSXNode): JSXNode[] { + const children = jsx.children; + if (Array.isArray(children)) { + return children as any; + } else if (children != null) { + return [children] as any; + } + return []; +} + +export function jsxToHTML(jsx: JSXNode, pad: string = ''): string { + const html: string[] = []; + if (jsx.type) { + html.push(pad, '<', tagToString(jsx.type), '>\n'); + getJSXChildren(jsx).forEach((jsx) => { + html.push(jsxToHTML(jsx, pad + ' ')); + }); + html.push(pad, '\n'); + } else { + html.push(pad, JSON.stringify(jsx), '\n'); + } + return html.join(''); +} diff --git a/packages/qwik/src/core/client/diffJsxVNode.ts b/packages/qwik/src/core/client/diffJsxVNode.ts new file mode 100644 index 00000000000..9a8a9ad9aef --- /dev/null +++ b/packages/qwik/src/core/client/diffJsxVNode.ts @@ -0,0 +1,172 @@ +import type { JSXNode, _ElementVNode, _TextVNode, _VNode } from '@builder.io/qwik'; +import { Fragment } from '@builder.io/qwik'; +import { + vnode_getAttr, + vnode_getAttrKeys, + vnode_getElementName, + vnode_getFirstChild, + vnode_getNextSibling, + vnode_getNode, + vnode_getText, + vnode_isElementVNode, + vnode_isTextVNode, + vnode_isVirtualVNode, +} from './vnode'; +import { serializeBooleanOrNumberAttribute } from '../shared/utils/styles'; +import { isHtmlAttributeAnEventName, isJsxPropertyAnEventName } from '../shared/utils/event-names'; +import { Q_PROPS_SEPARATOR } from '../shared/utils/markers'; +import { attrsEqual, getJSXChildren, jsxToHTML, tagToString } from './diffJsx-utils'; + +export function getVNodeChildren(vNode: _VNode): _VNode[] { + const children: _VNode[] = []; + let child = vnode_getFirstChild(vNode); + while (child) { + if (!shouldSkip(child)) { + children.push(child); + } + child = vnode_getNextSibling(child); + } + return children; +} + +export function diffJsxVNode( + received: _VNode, + expected: JSXNode | string, + path: string[] = [] +): string[] { + if (!received) { + return [path.join(' > ') + ' missing']; + } + const diffs: string[] = []; + if (typeof expected === 'string') { + const receivedText = vnode_isTextVNode(received) ? vnode_getText(received as _TextVNode) : null; + if (expected !== receivedText) { + diffs.push(path.join(' > ')); + diffs.push('EXPECTED', JSON.stringify(expected)); + diffs.push('RECEIVED:', JSON.stringify(receivedText)); + } + } else { + path.push(tagToString(expected.type)); + const receivedTag = vnode_isElementVNode(received) + ? vnode_getElementName(received as _ElementVNode) + : vnode_isVirtualVNode(received) + ? Fragment + : undefined; + const isTagSame = String(expected.type).toLowerCase() == String(receivedTag).toLowerCase(); + if (!isTagSame) { + diffs.push(path.join(' > ') + ' expecting=' + expected.type + ' received=' + receivedTag); + } + const allProps: string[] = []; + expected.varProps && propsAdd(allProps, Object.keys(expected.varProps)); + expected.constProps && propsAdd(allProps, Object.keys(expected.constProps)); + const receivedElement = vnode_isElementVNode(received) + ? (vnode_getNode(received) as Element) + : null; + propsAdd(allProps, vnode_isElementVNode(received) ? vnode_getAttrKeys(received).sort() : []); + receivedElement && propsAdd(allProps, constPropsFromElement(receivedElement)); + allProps.sort(); + allProps.forEach((prop) => { + if (isJsxPropertyAnEventName(prop) || isHtmlAttributeAnEventName(prop)) { + return; + } + // we need this, because Domino lowercases all attributes for `element.attributes` + const propLowerCased = prop.toLowerCase(); + let receivedValue = + vnode_getAttr(received, prop) || + vnode_getAttr(received, propLowerCased) || + receivedElement?.getAttribute(prop) || + receivedElement?.getAttribute(propLowerCased); + let expectedValue = + prop === 'key' || prop === 'q:key' ? (expected.key ?? receivedValue) : expected.props[prop]; + if (typeof receivedValue === 'boolean' || typeof receivedValue === 'number') { + receivedValue = serializeBooleanOrNumberAttribute(receivedValue); + } + if (typeof expectedValue === 'number') { + expectedValue = serializeBooleanOrNumberAttribute(expectedValue); + } + if (!attrsEqual(expectedValue, receivedValue)) { + diffs.push(`${path.join(' > ')}: [${prop}]`); + diffs.push(' EXPECTED: ' + JSON.stringify(expectedValue)); + diffs.push(' RECEIVED: ' + JSON.stringify(receivedValue)); + } + }); + const receivedChildren = getVNodeChildren(received); + const expectedChildren = getJSXChildren(expected); + if (receivedChildren.length === expectedChildren.length) { + for (let i = 0; i < receivedChildren.length; i++) { + const receivedChild = receivedChildren[i]; + const expectedChild = expectedChildren[i]; + diffs.push(...diffJsxVNode(receivedChild, expectedChild, path)); + } + } else { + diffs.push( + `${path.join(' > ')} expecting ${expectedChildren.length} children but was ${receivedChildren.length}` + ); + diffs.push('EXPECTED', jsxToHTML(expected, ' ')); + diffs.push('RECEIVED:', vnodeToHTML(received, ' ')); + } + path.pop(); + } + return diffs; +} + +function vnodeToHTML(vNode: _VNode | null, pad: string = ''): string { + const html: string[] = []; + while (vNode) { + html.push( + pad + + vNode + .toString() + .split('\n') + .join('\n' + pad) + ); + while (shouldSkip((vNode = vnode_getNextSibling(vNode!)))) { + // skip + } + } + return html.join(''); +} + +function propsAdd(existing: string[], incoming: string[]) { + for (const prop of incoming) { + if (prop !== 'children') { + let found = false; + for (let i = 0; i < existing.length; i++) { + if (existing[i].toLowerCase() === prop.toLowerCase()) { + found = true; + break; + } + } + if (!found) { + existing.push(prop); + } + } + } +} + +function constPropsFromElement(element: Element) { + const props: string[] = []; + for (let i = 0; i < element.attributes.length; i++) { + const attr = element.attributes[i]; + if (attr.name !== '' && attr.name !== Q_PROPS_SEPARATOR) { + props.push(attr.name); + } + } + props.sort(); + return props; +} + +function shouldSkip(vNode: _VNode | null) { + if (vNode && vnode_isElementVNode(vNode)) { + const tag = vnode_getElementName(vNode); + if ( + tag === 'script' && + (vnode_getAttr(vNode, 'type') === 'qwik/vnode' || + vnode_getAttr(vNode, 'type') === 'x-qwik/vnode' || + vnode_getAttr(vNode, 'type') === 'qwik/state') + ) { + return true; + } + } + return false; +} diff --git a/packages/qwik/src/core/client/dom-container.ts b/packages/qwik/src/core/client/dom-container.ts index d4e02f37e0d..a7617443f92 100644 --- a/packages/qwik/src/core/client/dom-container.ts +++ b/packages/qwik/src/core/client/dom-container.ts @@ -53,8 +53,6 @@ import { } from './types'; import { VNodeJournalOpCode, - mapArray_get, - mapArray_set, vnode_applyJournal, vnode_getDOMChildNodes, vnode_getDomParent, @@ -69,6 +67,7 @@ import { vnode_setProp, type VNodeJournal, } from './vnode'; +import { mapArray_get, mapArray_set } from './mapArray'; import { vnode_diff } from './vnode-diff'; /** @public */ diff --git a/packages/qwik/src/core/client/mapArray.ts b/packages/qwik/src/core/client/mapArray.ts new file mode 100644 index 00000000000..9fb36c5ad31 --- /dev/null +++ b/packages/qwik/src/core/client/mapArray.ts @@ -0,0 +1,70 @@ +import { assertTrue } from '../shared/error/assert'; + +export const mapApp_findIndx = ( + elementVNode: (T | null)[], + key: string, + start: number +): number => { + assertTrue(start % 2 === 0, 'Expecting even number.'); + let bottom = (start as number) >> 1; + let top = (elementVNode.length - 2) >> 1; + while (bottom <= top) { + const mid = bottom + ((top - bottom) >> 1); + const midKey = elementVNode[mid << 1] as string; + if (midKey === key) { + return mid << 1; + } + if (midKey < key) { + bottom = mid + 1; + } else { + top = mid - 1; + } + } + return (bottom << 1) ^ -1; +}; + +export const mapArray_set = ( + elementVNode: (T | null)[], + key: string, + value: T | null, + start: number +) => { + const indx = mapApp_findIndx(elementVNode, key, start); + if (indx >= 0) { + if (value == null) { + elementVNode.splice(indx, 2); + } else { + elementVNode[indx + 1] = value; + } + } else if (value != null) { + elementVNode.splice(indx ^ -1, 0, key as any, value); + } +}; + +export const mapApp_remove = ( + elementVNode: (T | null)[], + key: string, + start: number +): T | null => { + const indx = mapApp_findIndx(elementVNode, key, start); + let value: T | null = null; + if (indx >= 0) { + value = elementVNode[indx + 1]; + elementVNode.splice(indx, 2); + return value; + } + return value; +}; + +export const mapArray_get = ( + elementVNode: (T | null)[], + key: string, + start: number +): T | null => { + const indx = mapApp_findIndx(elementVNode, key, start); + if (indx >= 0) { + return elementVNode[indx + 1] as T | null; + } else { + return null; + } +}; diff --git a/packages/qwik/src/core/client/vnode-diff.ts b/packages/qwik/src/core/client/vnode-diff.ts index 9bff54f7229..3a6b9832aef 100644 --- a/packages/qwik/src/core/client/vnode-diff.ts +++ b/packages/qwik/src/core/client/vnode-diff.ts @@ -58,8 +58,6 @@ import { type VirtualVNode, } from './types'; import { - mapApp_findIndx, - mapArray_set, vnode_ensureElementInflated, vnode_getAttr, vnode_getDomParentVNode, @@ -90,6 +88,8 @@ import { vnode_walkVNode, type VNodeJournal, } from './vnode'; +import { mapApp_findIndx } from './mapArray'; +import { mapArray_set } from './mapArray'; import { getNewElementNamespaceData } from './vnode-namespace'; import { WrappedSignal, EffectProperty, isSignal, EffectData } from '../signal/signal'; import type { Signal } from '../signal/signal.public'; diff --git a/packages/qwik/src/core/client/vnode.ts b/packages/qwik/src/core/client/vnode.ts index 7685410aa25..160f68db3a8 100644 --- a/packages/qwik/src/core/client/vnode.ts +++ b/packages/qwik/src/core/client/vnode.ts @@ -169,6 +169,7 @@ import { vnode_getElementNamespaceFlags, } from './vnode-namespace'; import { escapeHTML } from '../shared/utils/character-escaping'; +import { mapArray_set, mapArray_get, mapApp_findIndx } from './mapArray'; ////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -938,77 +939,6 @@ export const vnode_applyJournal = (journal: VNodeJournal) => { ////////////////////////////////////////////////////////////////////////////////////////////////////// -export const mapApp_findIndx = ( - elementVNode: (T | null)[], - key: string, - start: number -): number => { - assertTrue(start % 2 === 0, 'Expecting even number.'); - let bottom = (start as number) >> 1; - let top = (elementVNode.length - 2) >> 1; - while (bottom <= top) { - const mid = bottom + ((top - bottom) >> 1); - const midKey = elementVNode[mid << 1] as string; - if (midKey === key) { - return mid << 1; - } - if (midKey < key) { - bottom = mid + 1; - } else { - top = mid - 1; - } - } - return (bottom << 1) ^ -1; -}; - -export const mapArray_set = ( - elementVNode: (T | null)[], - key: string, - value: T | null, - start: number -) => { - const indx = mapApp_findIndx(elementVNode, key, start); - if (indx >= 0) { - if (value == null) { - elementVNode.splice(indx, 2); - } else { - elementVNode[indx + 1] = value; - } - } else if (value != null) { - elementVNode.splice(indx ^ -1, 0, key as any, value); - } -}; - -export const mapApp_remove = ( - elementVNode: (T | null)[], - key: string, - start: number -): T | null => { - const indx = mapApp_findIndx(elementVNode, key, start); - let value: T | null = null; - if (indx >= 0) { - value = elementVNode[indx + 1]; - elementVNode.splice(indx, 2); - return value; - } - return value; -}; - -export const mapArray_get = ( - elementVNode: (T | null)[], - key: string, - start: number -): T | null => { - const indx = mapApp_findIndx(elementVNode, key, start); - if (indx >= 0) { - return elementVNode[indx + 1] as T | null; - } else { - return null; - } -}; - -////////////////////////////////////////////////////////////////////////////////////////////////////// - export const vnode_insertBefore = ( journal: VNodeJournal, parent: ElementVNode | VirtualVNode, diff --git a/packages/qwik/src/core/shared/component-execution.ts b/packages/qwik/src/core/shared/component-execution.ts index e803ba50de6..068e1f730c0 100644 --- a/packages/qwik/src/core/shared/component-execution.ts +++ b/packages/qwik/src/core/shared/component-execution.ts @@ -1,7 +1,7 @@ import { isDev } from '@builder.io/qwik/build'; import { isQwikComponent, type OnRenderFn } from './component.public'; import { assertDefined } from './error/assert'; -import { isQrl, type QRLInternal } from './qrl/qrl-class'; +import type { QRLInternal } from './qrl/qrl-class'; import { JSXNodeImpl, isJSXNode, type Props } from './jsx/jsx-runtime'; import type { JSXNode, JSXOutput } from './jsx/types/jsx-node'; import type { KnownEventNames } from './jsx/types/jsx-qwik-events'; @@ -23,6 +23,7 @@ import { logWarn } from './utils/log'; import { EffectProperty, isSignal } from '../signal/signal'; import { vnode_isVNode } from '../client/vnode'; import { clearVNodeEffectDependencies } from '../signal/signal-subscriber'; +import { isQrl } from './qrl/qrl-utils'; /** * Use `executeComponent` to execute a component. diff --git a/packages/qwik/src/core/shared/platform/platform.ts b/packages/qwik/src/core/shared/platform/platform.ts index e3767cad8d8..677a02d6e97 100644 --- a/packages/qwik/src/core/shared/platform/platform.ts +++ b/packages/qwik/src/core/shared/platform/platform.ts @@ -1,6 +1,6 @@ import { isServer } from '@builder.io/qwik/build'; import { qError, QError_qrlMissingChunk, QError_qrlMissingContainer } from '../error/error'; -import { getSymbolHash } from '../qrl/qrl-class'; +import { getSymbolHash } from '../qrl/qrl-utils'; import { qDynamicPlatform } from '../utils/qdev'; import type { CorePlatform } from './types'; diff --git a/packages/qwik/src/core/shared/qrl/qrl-class.ts b/packages/qwik/src/core/shared/qrl/qrl-class.ts index b748a902609..42583740603 100644 --- a/packages/qwik/src/core/shared/qrl/qrl-class.ts +++ b/packages/qwik/src/core/shared/qrl/qrl-class.ts @@ -15,17 +15,9 @@ import { getQFuncs, QInstanceAttr } from '../utils/markers'; import { isPromise, maybeThen } from '../utils/promises'; import { qDev, qSerialize, qTest, seal } from '../utils/qdev'; import { isArray, isFunction, type ValueOrPromise } from '../utils/types'; -import { isSignal } from '../../signal/signal'; import type { QRLDev } from './qrl'; import type { QRL, QrlArgs, QrlReturn } from './qrl.public'; -import type { Signal } from '../../signal/signal.public'; - -export const isQrl = (value: unknown): value is QRLInternal => { - return typeof value === 'function' && typeof (value as any).getSymbol === 'function'; -}; - -// Make sure this value is same as value in `platform.ts` -export const SYNC_QRL = ''; +import { getSymbolHash, SYNC_QRL } from './qrl-utils'; interface SyncQRLSymbol { $symbol$: typeof SYNC_QRL; @@ -33,11 +25,6 @@ interface SyncQRLSymbol { export type SyncQRLInternal = QRLInternal & SyncQRLSymbol; -/** Sync QRL is a function which is serialized into `