diff --git a/.github/actions/setup-build-test/action.yml b/.github/actions/setup-build-test/action.yml index a68f74c..4d4395c 100644 --- a/.github/actions/setup-build-test/action.yml +++ b/.github/actions/setup-build-test/action.yml @@ -45,6 +45,11 @@ runs: # Only truthy when licensed; empty string keeps unlicensed builds unscoped. IG_LICENSED_BUILD: ${{ inputs.licensed == 'true' && 'true' || '' }} + # Needs the generated wrappers. + - name: Type-check tests + shell: bash + run: npm run typecheck:tests + - name: Validate dist packages shell: bash run: npm run validate:dist ${{ inputs.verbose == 'true' && '--loglevel verbose' || '' }} diff --git a/package.json b/package.json index d24365b..f908793 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "generate:grids": "tsx ./scripts/generate.ts grids", "generate:grid-lite": "tsx ./scripts/generate.ts grid-lite", "typecheck:scripts": "tsc --project ./scripts/tsconfig.json", + "typecheck:tests": "tsc --project ./tests/tsconfig.json", "check:manifests": "tsx ./scripts/check-manifests.ts", "validate:dist": "tsx ./scripts/validate-dist.ts", "test:browser": "vitest", diff --git a/src/backfills.ts b/src/backfills.ts index 34a545a..7b0a1e9 100644 --- a/src/backfills.ts +++ b/src/backfills.ts @@ -1,3 +1,5 @@ +import { isObject } from './is-object.js'; + const DATA_CONTEXT_PROP = 'dataContext'; let once = false; @@ -6,6 +8,11 @@ let once = false; * the `dataContext` prop returning the original data */ export function withDataContext(data: unknown) { + // Proxy throws on primitives. + if (!isObject(data)) { + return data; + } + return new Proxy(data as any, { get(target, prop, receiver) { if (prop === DATA_CONTEXT_PROP) { diff --git a/src/equal.ts b/src/equal.ts index 54492f3..6503604 100644 --- a/src/equal.ts +++ b/src/equal.ts @@ -1,91 +1,115 @@ -export function equal(a: unknown, b: T, visited = new WeakSet()): boolean { +import { isObject } from './is-object.js'; + +type Visited = WeakMap>; + +export function equal(a: unknown, b: T, visited?: Visited): boolean { // Early return - if (Object.is(a, b)) return true; + if (Object.is(a, b)) { + return true; + } - if (isObject(a) && isObject(b)) { - if (a.constructor !== b.constructor) return false; + if (!isObject(a) || !isObject(b)) { + return false; + } - // Circular references - if (visited.has(a) && visited.has(b)) { - return true; - } + if (a.constructor !== b.constructor) { + return false; + } - visited.add(a); - visited.add(b); - - // RegExp - if (isRegExp(a) && isRegExp(b)) return a.source === b.source && a.flags === b.flags; - - // Maps - if (a instanceof Map && b instanceof Map) { - if (a.size !== b.size) return false; - for (const [keyA, valueA] of a.entries()) { - let found = false; - for (const [keyB, valueB] of b.entries()) { - if (equal(keyA, keyB, visited) && equal(valueA, valueB, visited)) { - found = true; - break; - } - } - if (!found) return false; - } - return true; - } + // Track pairs, not objects: failed Set/Map probes must not count as visited. + const pairs: Visited = visited ?? new WeakMap(); + const pending = pairs.get(a) ?? new WeakSet(); - // Sets - if (a instanceof Set && b instanceof Set) { - if (a.size !== b.size) return false; - for (const valueA of a) { - let found = false; - for (const valueB of b) { - if (equal(valueA, valueB, visited)) { - found = true; - break; - } - } - if (!found) return false; - } - return true; + if (pending.has(b)) { + return true; + } + + pairs.set(a, pending.add(b)); + + try { + return compare(a, b, pairs); + } finally { + pending.delete(b); + } +} + +function compare(a: object, b: object, visited: Visited): boolean { + // RegExp + if (isRegExp(a) && isRegExp(b)) { + return a.source === b.source && a.flags === b.flags; + } + + // Map entries iterate as [key, value] arrays. + if ((a instanceof Map && b instanceof Map) || (a instanceof Set && b instanceof Set)) { + return a.size === b.size && matchOneToOne(a, b, visited); + } + + // Arrays + if (Array.isArray(a) && Array.isArray(b)) { + const length = a.length; + + if (length !== b.length) { + return false; } - // Arrays - if (Array.isArray(a) && Array.isArray(b)) { - const length = a.length; - if (length !== b.length) return false; - for (let i = 0; i < length; i++) { - if (!equal(a[i], b[i], visited)) return false; + for (let i = 0; i < length; i++) { + if (!equal(a[i], b[i], visited)) { + return false; } - return true; } + return true; + } - // toPrimitive - if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); - // Strings based - if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); + // toPrimitive; absent on null-prototype objects. + if (typeof a.valueOf === 'function' && a.valueOf !== Object.prototype.valueOf) { + return a.valueOf() === b.valueOf(); + } - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; + // Strings based + if (typeof a.toString === 'function' && a.toString !== Object.prototype.toString) { + return a.toString() === b.toString(); + } - for (const key of aKeys) { - if (!Object.hasOwn(b, key)) return false; - } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); - for (const key of aKeys) { - if (!equal(a[key as keyof typeof a], b[key as keyof typeof b], visited)) return false; - } + if (aKeys.length !== bKeys.length) { + return false; + } - visited.delete(a); - visited.delete(b); + for (const key of aKeys) { + if (!Object.hasOwn(b, key)) { + return false; + } + } - return true; + for (const key of aKeys) { + if (!equal(a[key as keyof typeof a], b[key as keyof typeof b], visited)) { + return false; + } } - return false; + return true; } -function isObject(value: unknown): value is object { - return value != null && typeof value === 'object'; +/** Greedy suffices: `equal` is transitive. */ +function matchOneToOne( + left: Iterable, + right: Iterable, + visited: Visited, +): boolean { + const pool = [...right]; + + for (const a of left) { + const index = pool.findIndex((b) => equal(a, b, visited)); + + if (index < 0) { + return false; + } + pool.splice(index, 1); + } + + return true; } function isRegExp(value: unknown): value is RegExp { diff --git a/src/extras/index.tsx b/src/extras/index.tsx index 050a894..78af300 100644 --- a/src/extras/index.tsx +++ b/src/extras/index.tsx @@ -1,15 +1,16 @@ import type { IgcChatMessage } from 'igniteui-webcomponents'; import type { MarkdownRendererOptions } from 'igniteui-webcomponents/extras'; import { setupMarkdownRenderer } from 'igniteui-webcomponents/extras'; +import type { ReactNode } from 'react'; export type IgrChatMarkdownRendererOptions = MarkdownRendererOptions; export async function createChatMarkdownRenderer( options: IgrChatMarkdownRendererOptions, -): Promise<(message: IgcChatMessage) => Promise> { +): Promise<(message: IgcChatMessage) => Promise> { const renderer = await setupMarkdownRenderer(options); - return async (message: IgcChatMessage): Promise => { + return async (message: IgcChatMessage): Promise => { return (
{ + return value !== null && typeof value === 'object'; +} diff --git a/src/react-props.tsx b/src/react-props.tsx index c457c9f..5711d67 100644 --- a/src/react-props.tsx +++ b/src/react-props.tsx @@ -4,44 +4,52 @@ import { createComponent as _createComponent, type EventName, type Options } fro import { html } from 'lit'; import type React from 'react'; import { createPortal } from 'react-dom'; -import type { WithDataContext } from './backfills.js'; +import { type WithDataContext, withDataContext } from './backfills.js'; +import { isObject } from './is-object.js'; import { REQUEST_REMOVE, type RendererRequest, requestRenderer } from './render-props.js'; export type { EventName } from '@lit/react'; -type DistributiveOmit = T extends any +type DistributiveOmit = T extends any ? K extends keyof T ? Omit : T : T; type PropsWithoutRef = DistributiveOmit; -// A key value map matching React prop names to event names. +// React prop → event name. type EventNames = Record; -// A map of expected event listener types based on EventNames. type EventListeners = { [K in keyof R]?: R[K] extends EventName ? (e: R[K]['__eventType']) => void : (e: Event) => void; }; type ElementProps = Partial>; -// Acceptable props to the React component. type ComponentProps = Omit< React.HTMLAttributes, - // Prefer type of provided event handler props or those on element over - // built-in HTMLAttributes + // Element and event props win over HTMLAttributes. keyof E | keyof ElementProps > & EventListeners & ElementProps; -/** Mapped type to update the render props callback return type */ -type WithJsxRenderProps = { +/** Prop name → renderer name, or a nested map for config props (e.g. `igc-chat`). */ +type Renderers = Record; + +/** React 18's `ReactNode` lacks promises. */ +type RenderResult = React.ReactNode | PromiseLike; + +/** Render props return `RenderResult`; nested maps recurse. */ +type WithJsxRenderProps = { [K in keyof T]: K extends keyof R - ? NonNullable extends (...args: infer Args) => unknown - ? (...args: WithDataContext) => React.ReactNode - : T[K] + ? R[K] extends string + ? NonNullable extends (...args: infer Args) => unknown + ? ((...args: WithDataContext) => RenderResult) | Extract + : T[K] + : R[K] extends Renderers + ? WithJsxRenderProps, R[K]> | Extract + : T[K] : T[K]; }; @@ -55,7 +63,24 @@ export type ReactWebComponent< PropsWithoutRef, R>> & React.RefAttributes >; -type Renderers = Record; +type ReactModule = typeof React; + +type Props = Record; +type RenderProp = (data: unknown) => RenderResult; +type Template = (ctx: unknown) => unknown; + +type Reparenting = 'move-back' | 'none'; +/** Cleared render prop: keep its template, or unset it. */ +type Clearing = 'keep' | 'unset'; +type NgElement = HTMLElement & { ngElementStrategy?: { parentElement?: WeakRef } }; + +type PortalSlot = RendererRequest & { + callback?: RenderProp; + portal?: React.ReactPortal; +}; + +type SlotProps = { render: RenderProp; data: unknown }; +type SlotRenderer = (props: SlotProps) => React.ReactNode; interface WrapperOptions extends Options { @@ -63,6 +88,304 @@ interface WrapperOptions(); + private readonly _slots = new Map(); + + /** Reused while shallow-equal: a new object updates the element. */ + private readonly _containers = new Map(); + + constructor( + private readonly _renderers: Renderers, + private readonly _clearing: Clearing, + private readonly _slot: SlotRenderer, + private readonly _notify: () => void, + ) {} + + public resolve(props: Props): { props: Props; portals: React.ReactPortal[] } { + const callbacks = new Map(); + const elementProps = this._collect(props, this._renderers, callbacks); + + return { props: elementProps, portals: this._portals(callbacks) }; + } + + /** Arrow: templates call it unbound. */ + private readonly _request = (req: RendererRequest): void => { + if (req.data === REQUEST_REMOVE) { + this._slots.delete(req.slotName); + } else { + this._slots.set(req.slotName, { ...req, data: withDataContext(req.data) }); + } + + this._notify(); + }; + + private _collect( + props: Props, + renderers: Renderers, + callbacks: Map, + prefix = '', + ): Props { + const out: Props = {}; + + for (const prop in props) { + const path = toPath(prefix, prop); + const renderer = renderers[prop]; + const value = props[prop]; + + if (typeof renderer === 'string' && typeof value === 'function') { + callbacks.set(renderer, value as RenderProp); + out[prop] = this._template(path, renderer); + } else if (isObject(renderer) && isObject(value)) { + out[prop] = this._container(path, this._collect(value, renderer, callbacks, path)); + } else { + // Also non-function render props, e.g. `undefined`: the element uses its default. + out[prop] = value; + } + } + + if (this._clearing === 'unset') { + return out; + } + + // Cleared or dropped: keep; Angular Elements throw on an unset template. + for (const prop in renderers) { + const template = this._templates.get(toPath(prefix, prop)); + + if (template) { + out[prop] ??= template; + } + } + + return out; + } + + /** Captures only the renderer name, so it never goes stale. */ + private _template(path: string, name: string): Template { + let template = this._templates.get(path); + + if (!template) { + template = (ctx) => html`${requestRenderer(this._request, name, ctx)}`; + this._templates.set(path, template); + } + + return template; + } + + private _container(path: string, next: Props): Props { + const previous = this._containers.get(path); + + if (previous && shallowEqual(previous, next)) { + return previous; + } + + this._containers.set(path, next); + return next; + } + + private _portals(callbacks: Map): React.ReactPortal[] { + const portals: React.ReactPortal[] = []; + + for (const slot of this._slots.values()) { + const callback = callbacks.get(slot.name); + + // Prop removed: element releases the slot. + if (!callback) { + continue; + } + + portals.push(this._portal(slot, callback)); + } + + return portals; + } + + /** + * New render prop re-renders its slot: it may close over state the element can't see. + * Same one keeps its portal; a fresh portal would re-commit and loop. + */ + private _portal(slot: PortalSlot, callback: RenderProp): React.ReactPortal { + if (slot.portal && slot.callback === callback) { + return slot.portal; + } + + const content = this._slot({ render: callback, data: slot.data }); + + slot.callback = callback; + slot.portal = createPortal(content, slot.node, slot.slotName); + return slot.portal; + } +} + +/** + * Each slot is its own React tree, isolated from its neighbors: + * + * Boundary throws or rejects → empty slot; React logs it + * └ Suspense `use()` → empty slot until resolved + * └ Content calls the render prop + * └ Pending async result; keeps the previous one until it settles + */ +function createSlot(react: ReactModule): SlotRenderer { + type BoundaryState = Partial & { failed: boolean }; + + class Boundary extends react.Component { + public state: BoundaryState = { failed: false }; + + /** New render prop or data: retry. */ + public static getDerivedStateFromProps( + { render, data }: SlotProps, + state: BoundaryState, + ): BoundaryState | null { + if (render === state.render && data === state.data) { + return null; + } + + return { failed: false, render, data }; + } + + public static getDerivedStateFromError(): Partial { + return { failed: true }; + } + + public render(): React.ReactNode { + if (this.state.failed) { + return null; + } + + return react.createElement( + react.Suspense, + { fallback: null }, + react.createElement(Content, this.props), + ); + } + } + + // Not `use()`: a new promise per render never settles. + function Pending({ promise }: { promise: PromiseLike }): React.ReactNode { + const [content, setContent] = react.useState(null); + const [, setFailure] = react.useState(); + + react.useEffect(() => { + let current = true; + + // Drops superseded results and failures. + promise.then( + (resolved) => { + if (!current) { + return; + } + + // Boxed: a function would run as an updater. + setContent(() => resolved); + }, + (reason: unknown) => { + if (!current) { + return; + } + + // Throws on render, into the boundary. + setFailure(() => { + throw reason; + }); + }, + ); + + return () => { + current = false; + }; + }, [promise]); + + return content ?? null; + } + + function Content({ render, data }: SlotProps): React.ReactNode { + const content = render(data); + + // React 17 throws on `undefined`. + if (!isThenable(content)) { + return content ?? null; + } + + // Handled now: a render that never commits never mounts `Pending`. + content.then(undefined, () => {}); + return react.createElement(Pending, { promise: content }); + } + + return (props) => react.createElement(Boundary, props); +} + +function useTemplateBridge( + react: ReactModule, + renderers: Renderers, + clearing: Clearing, + slot: SlotRenderer, +): TemplateBridge { + const [, forceUpdate] = react.useReducer((n: number) => n + 1, 0); + const [bridge] = react.useState(() => new TemplateBridge(renderers, clearing, slot, forceUpdate)); + + return bridge; +} + +function useForwardedRef( + react: ReactModule, + ref: React.ForwardedRef, +): readonly [React.RefObject, (node: I) => void] { + const elementRef = react.useRef(null); + + const setRef = react.useCallback( + (node: I) => { + elementRef.current = node; + + if (typeof ref === 'function') { + ref(node); + } else if (ref !== null) { + ref.current = node; + } + }, + [ref], + ); + + return [elementRef, setRef]; +} + +/** Moves the element back to its Angular Elements parent before React unmounts it. */ +function useReparenting( + react: ReactModule, + mode: Reparenting, + elementRef: React.RefObject, +): void { + const projectionParent = react.useRef | null>(null); + + react.useLayoutEffect(() => { + if (mode === 'none') { + return; + } + + // Strict mode re-run: return to the projection parent. + const prevParent = projectionParent.current?.deref(); + + if (prevParent && elementRef.current && prevParent !== elementRef.current.parentElement) { + prevParent.appendChild(elementRef.current); + projectionParent.current = null; + } + + // Runs before DOM removal. + return () => { + const element = elementRef.current; + const creationParent = element?.ngElementStrategy?.parentElement?.deref(); + + if (!element || !creationParent || creationParent === element.parentElement) { + return; + } + + if (element.parentElement) { + projectionParent.current = new WeakRef(element.parentElement); + } + creationParent.appendChild(element); + }; + }, [mode, elementRef]); +} + export const createComponent = < I extends HTMLElement, E extends EventNames = {}, @@ -76,173 +399,65 @@ export const createComponent = < renderProps, moveBackOnDelete, }: WrapperOptions): ReactWebComponent => { - // Register our components if ('register' in elementClass) { (elementClass as { register: () => void }).register(); } - if (!renderProps && !moveBackOnDelete) { - // When R is empty (no renderProps), the component types are equivalent at runtime - return _createComponent({ - react: React, - tagName, - elementClass, - events, - displayName, - }) as unknown as ReactWebComponent; - } - - type Props = ComponentProps; - - const safeEvents = events ?? ({} as E); const component = _createComponent({ react: React, tagName, elementClass, - events: safeEvents, + events, displayName, }); - type PropsWithRenderProps = WithJsxRenderProps; - - return React.forwardRef((props, ref) => { - const listeners = React.useRef(new Map()); - const elementRef = React.useRef(null); - const projectionParent = React.useRef | null>(null); - const [renderers, setRenderers] = React.useState(new Map()); - const outProps: Record = {}; - const portals: Record React.ReactNode> = {}; - - // https://react.dev/learn/reusing-logic-with-custom-hooks#keep-your-custom-hooks-focused-on-concrete-high-level-use-cases - // Runs once after first render to handle element re-parenting for Angular integration. - React.useLayoutEffect(() => { - if (!moveBackOnDelete) return; - - // already too late to save elementRef.current?.parentElement, rely on Elements - // secondary run (likely dev strict mode), move back to projection: - const prevParent = projectionParent.current?.deref(); - if (prevParent && elementRef.current && prevParent !== elementRef.current.parentElement) { - prevParent.appendChild(elementRef.current); - projectionParent.current = null; - } - return () => { - // cleanup **before** component is removed from the DOM - const element = elementRef.current; - if (!element) return; - - const creationParent = ( - element as I & { ngElementStrategy?: { parentElement?: WeakRef } } - ).ngElementStrategy?.parentElement?.deref(); - if (creationParent && creationParent !== element.parentElement) { - // move back to original parent - if (element.parentElement) { - projectionParent.current = new WeakRef(element.parentElement); - } - creationParent.appendChild(element); - } - }; - }, [moveBackOnDelete]); - - // Don't wrap in an `useCallback` hook since there is no mechanism in React to dispose of the cached function(s), - // potentially leading to a memory leak/higher memory usage for heavily templated component instances. - const renderFunc = (req: RendererRequest) => { - if (req.data === REQUEST_REMOVE) { - renderers.delete(req.slotName); - } else { - renderers.set( - req.slotName, - createPortal(portals[req.name]?.(req.data), req.node, req.slotName), - ); - } - setRenderers(() => new Map(renderers)); - }; + if (!renderProps && !moveBackOnDelete) { + // Runtime shape matches; only types differ. + return component as unknown as ReactWebComponent; + } - const processProps = ( - propMap: Record, - propDefinitions: Record, - outProps: Record, - prefix = '', - ) => { - for (const prop in propMap) { - const fullPropName = prefix ? `${prefix}.${prop}` : prop; - const rendererName = propDefinitions?.[prop]; - - if (rendererName !== undefined && typeof rendererName === 'string') { - if (listeners.current.has(fullPropName)) { - outProps[prop] = listeners.current.get(fullPropName); - } else { - portals[rendererName] = propMap[prop] as (data: unknown) => React.ReactNode; - const patched = createPatched(renderFunc, rendererName); - outProps[prop] = patched; - listeners.current.set(fullPropName, patched); - } - } else if ( - typeof propMap[prop] === 'object' && - propMap[prop] !== null && - propDefinitions?.[prop] && - typeof propDefinitions[prop] === 'object' - ) { - outProps[prop] = {}; - processProps( - propMap[prop] as Record, - propDefinitions?.[prop] as Record, - outProps[prop] as Record, - fullPropName, - ); - } else { - outProps[prop] = propMap[prop]; - } - } - }; + const renderers: Renderers = renderProps ?? {}; + const reparenting: Reparenting = moveBackOnDelete ? 'move-back' : 'none'; + // `moveBackOnDelete` marks Angular Elements. + const clearing: Clearing = moveBackOnDelete ? 'keep' : 'unset'; + const slot = createSlot(React); - for (const key of listeners.current.keys()) { - if (!hasNestedProperty(props, key)) { - listeners.current.delete(key); - } - } + return React.forwardRef, R>>((props, ref) => { + const bridge = useTemplateBridge(React, renderers, clearing, slot); + const [elementRef, setRef] = useForwardedRef(React, ref); + useReparenting(React, reparenting, elementRef); - processProps(props, renderProps ?? {}, outProps); + const { props: elementProps, portals } = bridge.resolve(props as Props); - const propsWithChildren = props as Props & { children?: React.ReactNode }; - if (listeners.current.size) { - Object.assign(outProps, { - children: [...React.Children.toArray(propsWithChildren.children), ...renderers.values()], - }); - } else { - Object.assign(outProps, { - children: React.Children.toArray(propsWithChildren.children), - }); - } + // A bridge update keeps `props`: reuse the element, so @lit/react does not + // set every property again. Portals render into their nodes from anywhere. + const element = React.useMemo( + () => + React.createElement(component, { + ...elementProps, + ref: setRef, + } as PropsWithoutRef> & React.RefAttributes), + [props, setRef], + ); - return React.createElement(component, { - ...outProps, - ref: React.useCallback( - (node: I) => { - elementRef.current = node; - if (typeof ref === 'function') { - ref(node); - } else if (ref !== null) { - ref.current = node; - } - }, - [ref], - ), - } as PropsWithoutRef> & React.RefAttributes); + return React.createElement(React.Fragment, null, element, portals); }); }; -function createPatched(callback: (req: RendererRequest) => void, propertyName: string) { - return (ctx: unknown) => html`${requestRenderer(callback, propertyName, ctx)}`; +function toPath(prefix: string, prop: string): string { + return prefix ? `${prefix}.${prop}` : prop; } -function hasNestedProperty(object: Record, path: string): boolean { - const parts = path.split('.'); - let current: unknown = object; - for (const part of parts) { - if (current === undefined || current === null || typeof current !== 'object') { - return false; - } - current = (current as Record)[part]; +function isThenable(value: unknown): value is PromiseLike { + return isObject(value) && typeof value.then === 'function'; +} + +function shallowEqual(a: Props, b: Props): boolean { + const keys = Object.keys(a); + + if (keys.length !== Object.keys(b).length) { + return false; } - return current !== undefined; + + return keys.every((key) => Object.is(a[key], b[key])); } diff --git a/src/render-props.ts b/src/render-props.ts index edfbb3c..81d5d9a 100644 --- a/src/render-props.ts +++ b/src/render-props.ts @@ -5,82 +5,63 @@ import { type DirectiveParameters, directive, } from 'lit/async-directive.js'; -import { withDataContext } from './backfills.js'; import { equal } from './equal.js'; +import { isObject } from './is-object.js'; import { getUUID } from './random-uuid.js'; export const REQUEST_REMOVE = Symbol('renderer-remove'); const NOT_SET = Symbol('not-set'); -type NgState = T & { $implicit: unknown }; -type RendererState = { - previous: T; - current: T; -}; - -function createRequestData( - name: string, - data: T | typeof REQUEST_REMOVE, - node: Element, - key?: string, -): RendererRequest { - return { - name, - data: data === REQUEST_REMOVE ? data : withDataContext(data), - slotName: key !== undefined ? `${name}${key}` : name, - node, - }; -} - -export type RendererRequest = { - data: T | typeof REQUEST_REMOVE; +export type RendererRequest = { + data: unknown; name: string; slotName: string; node: Element; }; -type RendererCallback = (req: RendererRequest) => unknown; +type RendererCallback = (req: RendererRequest) => unknown; -class RequestRenderer extends AsyncDirective { +class RequestRenderer extends AsyncDirective { private readonly _key = getUUID(); private _part: WeakRef | null = null; - private _callback: WeakRef> | null = null; - - private _state = { previous: NOT_SET, current: undefined } as RendererState; + private _callback: WeakRef | null = null; private _name!: string; + private _data: unknown; + private _previous: unknown = NOT_SET; - private get _renderNode(): Element { - return this._part?.deref()?.parentNode as Element; - } - - private _shouldUpdateNG(_data: NgState): boolean { - /* Can't compare implicit, in main use case it'd be the cell value which might repeat, - be undefined or otherwise unrelated to the template content. Disabled for now. Reevaluate: */ + private _shouldUpdate(): boolean { + // Angular context: `implicit` (e.g. cell value) may repeat; always update. + if (isObject(this._data) && 'implicit' in this._data) { + return true; + } - // if (equal(data.$implicit, this._state.previous)) { - // return false; - // } + if (equal(this._previous, this._data)) { + return false; + } - // this._state.previous = data.$implicit as T; + this._previous = this._data; return true; } - private _shouldUpdate(): boolean { - const data = this._state.current; + private _request(callback: RendererCallback, data: unknown): void { + const node = this._part?.deref()?.parentNode as Element | undefined; - if (Reflect.has(data as NgState, 'implicit')) { - return this._shouldUpdateNG(data as NgState); + if (!node) { + return; } - if (equal(this._state.previous, data)) { - return false; - } + callback({ name: this._name, data, slotName: `${this._name}${this._key}`, node }); + } - this._state.previous = data; - return true; + private _sync(): void { + const callback = this._callback?.deref(); + + if (callback && this._shouldUpdate()) { + this._request(callback, this._data); + } } - public override render(_callback: any, _name: string, _data: T): symbol { + public override render(_callback: RendererCallback, _name: string, _data: unknown): symbol { return noChange; } @@ -88,32 +69,34 @@ class RequestRenderer extends AsyncDirective { part: ChildPart, [callback, name, data]: DirectiveParameters, ): symbol { - this._callback = new WeakRef(callback); + if (this._callback?.deref() !== callback) { + this._callback = new WeakRef(callback); + } + + this._part ??= new WeakRef(part); this._name = name; - this._state.current = data; - this._part = new WeakRef(part); + this._data = data; - if (this.isConnected && callback && this._shouldUpdate()) { - callback(createRequestData(this._name, this._state.current, this._renderNode, this._key)); + if (this.isConnected) { + this._sync(); } return noChange; } protected override reconnected(): void { - const callback = this._callback?.deref(); - if (callback && this._shouldUpdate()) { - callback(createRequestData(this._name, this._state.current, this._renderNode, this._key)); - } + this._sync(); } protected override disconnected(): void { const callback = this._callback?.deref(); + if (callback) { - callback(createRequestData(this._name, REQUEST_REMOVE as T, this._renderNode, this._key)); - // drop prev, so a reconnect would behave like initial - this._state.previous = NOT_SET as T; + this._request(callback, REQUEST_REMOVE); } + + // Reconnect acts like first render. + this._previous = NOT_SET; } } diff --git a/tests/backfills.spec.ts b/tests/backfills.spec.ts new file mode 100644 index 0000000..713ade1 --- /dev/null +++ b/tests/backfills.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { withDataContext } from '../src/backfills'; + +describe('withDataContext', () => { + it('passes non-object contexts through', () => { + expect(withDataContext(undefined)).toBeUndefined(); + expect(withDataContext(1)).toBe(1); + }); +}); diff --git a/tests/equal.spec.ts b/tests/equal.spec.ts index 6157515..9696937 100644 --- a/tests/equal.spec.ts +++ b/tests/equal.spec.ts @@ -337,4 +337,68 @@ describe('equal', () => { const set2 = new Set([]); expect(equal(set1, set2)).toBe(false); }); + + it('should not let failed Set candidate pairs poison later comparisons', () => { + const a = new Set([{ x: 1 }, { x: 2 }]); + const b = new Set([{ x: 2 }, { x: 1 }]); + expect(equal(a, b)).toBe(true); + + // `{ k: 'b' }` has no counterpart. + const left = new Set([{ k: 'z' }, { k: 'b' }, { k: 'x' }]); + const right = new Set([{ k: 'x' }, { k: 'y' }, { k: 'z' }]); + expect(equal(left, right)).toBe(false); + }); + + it('should not let failed Map candidate pairs poison later comparisons', () => { + // `{ k: 'b' }` has no counterpart. + const left = new Map([ + [{ k: 'z' }, 1], + [{ k: 'b' }, 1], + [{ k: 'x' }, 1], + ]); + const right = new Map([ + [{ k: 'x' }, 1], + [{ k: 'y' }, 1], + [{ k: 'z' }, 1], + ]); + expect(equal(left, right)).toBe(false); + }); + + it('should match Set elements one to one', () => { + expect(equal(new Set([{ x: 1 }, { x: 1 }]), new Set([{ x: 1 }, { x: 2 }]))).toBe(false); + expect(equal(new Set([{ x: 1 }, { x: 1 }]), new Set([{ x: 1 }, { x: 1 }]))).toBe(true); + }); + + it('should match Map entries one to one', () => { + const left = new Map([ + [{ k: 1 }, 'v'], + [{ k: 1 }, 'v'], + ]); + const right = new Map([ + [{ k: 1 }, 'v'], + [{ k: 2 }, 'v'], + ]); + expect(equal(left, right)).toBe(false); + }); + + it('should compare null-prototype objects', () => { + const create = (value: number) => Object.assign(Object.create(null), { a: value }); + + expect(equal(create(1), create(1))).toBe(true); + expect(equal(create(1), create(2))).toBe(false); + }); + + it('should still terminate on circular references', () => { + const a: Record = { name: 'a' }; + const b: Record = { name: 'a' }; + a.self = a; + b.self = b; + expect(equal(a, b)).toBe(true); + + const c: Record = { name: 'c' }; + const d: Record = { name: 'different' }; + c.self = c; + d.self = d; + expect(equal(c, d)).toBe(false); + }); }); diff --git a/tests/grid-lite/Templates.spec.tsx b/tests/grid-lite/Templates.spec.tsx new file mode 100644 index 0000000..4ab7a00 --- /dev/null +++ b/tests/grid-lite/Templates.spec.tsx @@ -0,0 +1,191 @@ +import { StrictMode } from 'react'; +import { afterEach, expect, test, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { + AsyncTemplate, + ClearedTemplate, + FailingTemplate, + FunctionResultTemplate, + OptionalTemplate, + StatefulTemplate, + SuspendingTemplate, +} from './Templates'; + +afterEach(() => vi.restoreAllMocks()); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const muteLog = () => vi.spyOn(console, 'error').mockImplementation(() => {}); + +test('render props observe updated React state', async () => { + render(); + + await expect.element(page.getByText('V:1/C:0', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Increment' }).click(); + + await expect.element(page.getByText('V:1/C:1', { exact: true })).toBeVisible(); + await expect.element(page.getByText('V:2/C:1', { exact: true })).toBeVisible(); +}); + +test('a render prop passed as undefined falls back to the default rendering', async () => { + render(); + + await expect.element(page.getByText('V:1', { exact: true })).toBeVisible(); + await expect.element(page.getByText('id', { exact: true })).toBeVisible(); +}); + +test('a cleared render prop falls back to the default rendering', async () => { + render(); + + await expect.element(page.getByText('V:1', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Clear template' }).click(); + + await expect.element(page.getByText('1', { exact: true })).toBeVisible(); + await expect.element(page.getByText('V:1', { exact: true })).not.toBeInTheDocument(); +}); + +test('a render prop added on a later render does not evict the existing ones', async () => { + render(); + + await expect.element(page.getByText('V:1', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Add header template' }).click(); + + await expect.element(page.getByText('H:id', { exact: true })).toBeVisible(); + await expect.element(page.getByText('V:1', { exact: true })).toBeVisible(); + await expect.element(page.getByText('V:2', { exact: true })).toBeVisible(); +}); + +test('an async render prop keeps its content until the new one settles', async () => { + let gate = Promise.withResolvers(); + const wait = vi.fn(() => gate.promise); + const open = () => { + gate.resolve(); + gate = Promise.withResolvers(); + }; + + render(); + + await expect.poll(() => wait.mock.calls.length).toBeGreaterThan(0); + open(); + await expect.element(page.getByText('A:1/C:0', { exact: true })).toBeVisible(); + + const initial = wait.mock.calls.length; + await page.getByRole('button', { name: 'Increment' }).click(); + + await expect.element(page.getByText('Count:1', { exact: true })).toBeVisible(); + await expect.element(page.getByText('A:1/C:0', { exact: true })).toBeVisible(); + + await expect.poll(() => wait.mock.calls.length).toBeGreaterThan(initial); + open(); + await expect.element(page.getByText('A:1/C:1', { exact: true })).toBeVisible(); + + // No render loop. + const settled = wait.mock.calls.length; + await delay(300); + expect(wait).toHaveBeenCalledTimes(settled); +}); + +test.each(['sync', 'async', 'nested'] as const)( + 'a %s render prop failure stays in its cell', + async (failure) => { + const log = muteLog(); + const report = vi.spyOn(window, 'reportError').mockImplementation(() => {}); + + render(); + + await expect.element(page.getByText('V:2', { exact: true })).toBeVisible(); + + // Logged once, by React: caught, not uncaught. + await vi.waitFor(() => expect(log.mock.calls.flat()).toContainEqual(new Error('boom'))); + expect(report).not.toHaveBeenCalled(); + + // Let the failure's re-render land. + await delay(100); + + await expect.element(page.getByText('V:2', { exact: true })).toBeVisible(); + await expect.element(page.getByText('Caught:boom', { exact: true })).not.toBeInTheDocument(); + }, +); + +test('a superseded async render prop does not report its failure', async () => { + const log = muteLog(); + let gate = Promise.withResolvers(); + const wait = vi.fn(() => gate.promise); + + render(); + + await expect.poll(() => wait.mock.calls.length).toBeGreaterThan(0); + + const stale = gate; + gate = Promise.withResolvers(); + const initial = wait.mock.calls.length; + + await page.getByRole('button', { name: 'Increment' }).click(); + await expect.poll(() => wait.mock.calls.length).toBeGreaterThan(initial); + + stale.reject(new Error('stale')); + gate.resolve(); + + await expect.element(page.getByText('A:1/C:1', { exact: true })).toBeVisible(); + expect(log.mock.calls.flat()).not.toContainEqual(new Error('stale')); +}); + +test('a render prop suspending with use() suspends its cell only', async () => { + const log = muteLog(); + const loads = new Map>(); + + const load = (id: number) => { + const entry = loads.get(id) ?? Promise.withResolvers(); + loads.set(id, entry); + return entry.promise; + }; + + render(); + + await expect.poll(() => loads.size).toBe(2); + await expect.element(page.getByText('Alice', { exact: true })).toBeVisible(); + + for (const [id, { resolve }] of loads) { + resolve(`S:${id}`); + } + + await expect.element(page.getByText('S:1', { exact: true })).toBeVisible(); + await expect.element(page.getByText('S:2', { exact: true })).toBeVisible(); + expect(log).not.toHaveBeenCalled(); +}); + +test('an async render prop rejecting under StrictMode leaves no unhandled rejection', async () => { + muteLog(); + const unhandled = vi.fn((event: PromiseRejectionEvent) => event.preventDefault()); + window.addEventListener('unhandledrejection', unhandled); + + try { + render( + + + , + ); + + await expect.element(page.getByText('V:2', { exact: true })).toBeVisible(); + await delay(100); + + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('unhandledrejection', unhandled); + } +}); + +test('an async render prop resolving to a function does not call it', async () => { + // React warns about function children. + muteLog(); + + render(); + + await expect.element(page.getByText('V:2', { exact: true })).toBeVisible(); + await delay(100); + + await expect.element(page.getByText('F', { exact: true })).not.toBeInTheDocument(); +}); diff --git a/tests/grid-lite/Templates.tsx b/tests/grid-lite/Templates.tsx new file mode 100644 index 0000000..c1fa1f7 --- /dev/null +++ b/tests/grid-lite/Templates.tsx @@ -0,0 +1,165 @@ +import { Component, type ComponentProps, type ReactNode, Suspense, use, useState } from 'react'; +import { + type IgrCellContext, + IgrGridLite, + IgrGridLiteColumn, + type IgrHeaderContext, +} from '../../src/grid-lite'; +import '../../node_modules/igniteui-webcomponents/themes/light/bootstrap.css'; + +interface Person { + id: number; + name: string; +} + +const data: Person[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, +]; + +type Templates = Pick, 'cellTemplate' | 'headerTemplate'>; + +function PeopleGrid(templates: Templates) { + return ( + + + + + ); +} + +export function StatefulTemplate() { + const [count, setCount] = useState(0); + + const cellTemplate = (ctx: IgrCellContext) => ( + + V:{ctx.value}/C:{count} + + ); + + return ( + <> + + + + ); +} + +export function OptionalTemplate() { + const [withHeader, setWithHeader] = useState(false); + + const cellTemplate = (ctx: IgrCellContext) => V:{ctx.value}; + const headerTemplate = (ctx: IgrHeaderContext) => H:{ctx.column.field}; + + return ( + <> + + + + ); +} + +type TemplateState = 'set' | 'cleared'; + +export function ClearedTemplate() { + const [template, setTemplate] = useState('set'); + + const cellTemplate = (ctx: IgrCellContext) => V:{ctx.value}; + + return ( + <> + + + + ); +} + +export function AsyncTemplate({ wait }: { wait: () => Promise }) { + const [count, setCount] = useState(0); + + const cellTemplate = async (ctx: IgrCellContext) => { + await wait(); + + return ( + + A:{ctx.value}/C:{count} + + ); + }; + + return ( + <> + + Count:{count} + + + ); +} + +type BoundaryState = { error?: Error }; + +class Boundary extends Component<{ children: ReactNode }, BoundaryState> { + state: BoundaryState = {}; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + return this.state.error ?

Caught:{this.state.error.message}

: this.props.children; + } +} + +/** Fails on the first row only. */ +function Cell({ value }: Pick, 'value'>) { + if (value === 1) { + throw new Error('boom'); + } + + return V:{value}; +} + +const failingTemplates = { + sync: (ctx: IgrCellContext) => Cell(ctx), + async: async (ctx: IgrCellContext) => Cell(ctx), + // Throws while React renders the returned tree. + nested: (ctx: IgrCellContext) => , +}; + +export function FailingTemplate({ failure }: { failure: keyof typeof failingTemplates }) { + return ( + + + + ); +} + +/** `use()` needs `load` to cache its promises. */ +export function SuspendingTemplate({ load }: { load: (id: number) => Promise }) { + const cellTemplate = (ctx: IgrCellContext) => {use(load(Number(ctx.value)))}; + + return ( + Loading grid

}> + +
+ ); +} + +/** First row resolves to a function: not a valid child, never called. */ +export function FunctionResultTemplate() { + const cellTemplate = async ({ value }: IgrCellContext) => + value === 1 ? ((() => F) as never) : V:{value}; + + return ; +} diff --git a/tests/grids/Grid.spec.tsx b/tests/grids/Grid.spec.tsx index b929ef3..fc37cf1 100644 --- a/tests/grids/Grid.spec.tsx +++ b/tests/grids/Grid.spec.tsx @@ -3,7 +3,7 @@ import { type Locator, page } from 'vitest/browser'; import { render } from 'vitest-browser-react'; import { configure } from 'vitest-browser-react/pure'; import type { IgrGrid } from '../../src/grids'; -import Grid from './Grid'; +import Grid, { ClearedTemplate, type Clearing } from './Grid'; afterAll(() => vi.restoreAllMocks()); @@ -92,3 +92,19 @@ test('should render correct template values on repeating cell values between row await checkRowTemplatedButtonId(rows); }); + +test.each(['undefined', 'omitted'])( + 'a render prop cleared as %s keeps data updates flowing', + async (clearing) => { + render(); + + await expect.element(page.getByRole('gridcell', { name: 'T:1' })).toBeVisible(); + + await page.getByRole('button', { name: 'Clear template' }).click(); + await page.getByRole('button', { name: 'Age' }).click(); + + for (const age of ['31', '41', '51']) { + await expect.element(page.getByRole('gridcell', { name: age, exact: true })).toBeVisible(); + } + }, +); diff --git a/tests/grids/Grid.tsx b/tests/grids/Grid.tsx index bd3f0cd..7dcb331 100644 --- a/tests/grids/Grid.tsx +++ b/tests/grids/Grid.tsx @@ -146,3 +146,37 @@ export default function Grid() { ); } + +type TemplateState = 'set' | 'cleared'; +export type Clearing = 'undefined' | 'omitted'; + +/** Angular Elements throw on an unset template; data updates must survive it. */ +export function ClearedTemplate({ clearing }: { clearing: Clearing }) { + const [template, setTemplate] = useState('set'); + const [data, setData] = useState([ + { id: 1, age: 30 }, + { id: 2, age: 40 }, + { id: 3, age: 50 }, + ]); + + const bodyTemplate = (ctx: IgrCellTemplateContext) => T:{ctx.cell.value}; + const age = () => setData((rows) => rows.map((row) => ({ ...row, age: row.age + 1 }))); + + const cleared = clearing === 'undefined' ? { bodyTemplate: undefined } : {}; + const templates = template === 'set' ? { bodyTemplate } : cleared; + + return ( + <> + + + + + + + + ); +} diff --git a/tests/grids/QueryBuilder.spec.tsx b/tests/grids/QueryBuilder.spec.tsx index 92814df..1618d26 100644 --- a/tests/grids/QueryBuilder.spec.tsx +++ b/tests/grids/QueryBuilder.spec.tsx @@ -19,7 +19,7 @@ test('QueryBuilder', async () => { const mockLog = vi.spyOn(console, 'log'); await entitySelect.click(); await expect.element(page.getByRole('option')).toBeVisible(); - await page.getByRole('option', { hasText: 'Customers' }).click(); + await page.getByRole('option').filter({ hasText: 'Customers' }).click(); await expect.element(entitySelect).toHaveValue('Customers'); expect(mockLog).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..74c1e7b --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,10 @@ +// vitest transpiles tests unchecked. +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "emitDeclarationOnly": false + }, + "include": [".", "../src/vite-env.d.ts"] +} diff --git a/tests/web-components/Chat.spec.tsx b/tests/web-components/Chat.spec.tsx index e4b9160..b34ab82 100644 --- a/tests/web-components/Chat.spec.tsx +++ b/tests/web-components/Chat.spec.tsx @@ -2,7 +2,7 @@ import { afterAll, expect, test, vi } from 'vitest'; import { locators, page, userEvent } from 'vitest/browser'; import { render } from 'vitest-browser-react'; -import Chat from './Chat'; +import Chat, { ToggledHeader } from './Chat'; afterAll(() => vi.restoreAllMocks()); @@ -67,6 +67,16 @@ test('Markdown support with default renderer', async () => { expect(message.element().tagName).toMatch(/a/i); }); +test('a renderer restored before the chat re-renders keeps its content', async () => { + render(); + + await expect.element(page.getByText('HDR', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Toggle' }).click(); + + await expect.element(page.getByText('HDR', { exact: true })).toBeVisible(); +}); + //#region Locator extension locators.extend({ getByPart(part: string) { diff --git a/tests/web-components/Chat.tsx b/tests/web-components/Chat.tsx index 5890a59..01681e6 100644 --- a/tests/web-components/Chat.tsx +++ b/tests/web-components/Chat.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react'; +import { flushSync } from 'react-dom'; import { type ChatMessageRenderContext, IgrChat } from '../../src/components'; import '../../node_modules/igniteui-webcomponents/themes/light/bootstrap.css'; import { createChatMarkdownRenderer } from '../../src/extras/index'; -const markdownRenderer = await createChatMarkdownRenderer(); +const markdownRenderer = await createChatMarkdownRenderer({}); export default function BasicForm() { const logEvent = (e: any) => console.log(e); @@ -44,3 +46,25 @@ export default function BasicForm() { > ); } + +export function ToggledHeader() { + const [enabled, setEnabled] = useState(true); + + const messages = [{ id: '1', text: 'Hello', sender: 'support', timestamp: '0' }]; + const messageHeader = () => HDR; + + // Both commits land before the element's queued update. + const toggle = () => { + flushSync(() => setEnabled(false)); + flushSync(() => setEnabled(true)); + }; + + return ( + <> + + + + ); +} diff --git a/tests/web-components/Combo.spec.tsx b/tests/web-components/Combo.spec.tsx new file mode 100644 index 0000000..44788a6 --- /dev/null +++ b/tests/web-components/Combo.spec.tsx @@ -0,0 +1,37 @@ +import { expect, test, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { TemplatedCombo } from './Combo'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +test('template renders do not set the element properties again', async () => { + render(); + + await page.getByRole('combobox').click(); + await expect.element(page.getByText('T:AAPL', { exact: true })).toBeVisible(); + + const combo = document.querySelector('igc-combo')!; + const sets = vi.spyOn(combo, 'singleSelect', 'set'); + + // Filters the list, so the item templates render again. + await userEvent.keyboard('n'); + await expect.element(page.getByText('T:NVDA', { exact: true })).toBeVisible(); + await delay(300); + + expect(sets).not.toHaveBeenCalled(); + await expect.element(page.getByText('T:AAPL', { exact: true })).not.toBeInTheDocument(); +}); + +test('a click in a template fires the onClick of the wrapper once', async () => { + const onClick = vi.fn(); + render(); + + await page.getByRole('combobox').click(); + await expect.element(page.getByText('T:MSFT', { exact: true })).toBeVisible(); + onClick.mockClear(); + + await page.getByText('T:MSFT', { exact: true }).click(); + + expect(onClick).toHaveBeenCalledOnce(); +}); diff --git a/tests/web-components/Combo.tsx b/tests/web-components/Combo.tsx new file mode 100644 index 0000000..40e7f11 --- /dev/null +++ b/tests/web-components/Combo.tsx @@ -0,0 +1,24 @@ +import { IgrCombo } from '../../src/components'; +import '../../node_modules/igniteui-webcomponents/themes/light/bootstrap.css'; + +interface Ticker { + symbol: string; +} + +const tickers: Ticker[] = ['AAPL', 'MSFT', 'NVDA', 'NFLX', 'TSLA', 'AMZN', 'META'].map( + (symbol) => ({ symbol }), +); + +export function TemplatedCombo({ onClick }: { onClick?: () => void }) { + return ( + T:{item.symbol}} + /> + ); +}