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/is-object.ts b/src/is-object.ts new file mode 100644 index 0000000..c425245 --- /dev/null +++ b/src/is-object.ts @@ -0,0 +1,3 @@ +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} diff --git a/src/react-props.tsx b/src/react-props.tsx index c457c9f..f7af3a5 100644 --- a/src/react-props.tsx +++ b/src/react-props.tsx @@ -4,44 +4,49 @@ 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; + +/** Render props return `ReactNode`; 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) => React.ReactNode) | Extract + : T[K] + : R[K] extends Renderers + ? WithJsxRenderProps, R[K]> | Extract + : T[K] : T[K]; }; @@ -55,7 +60,21 @@ export type ReactWebComponent< PropsWithoutRef, R>> & React.RefAttributes >; -type Renderers = Record; +type ReactModule = typeof React; + +type Props = Record; +type RenderProp = (data: unknown) => React.ReactNode; +type Template = (ctx: unknown) => unknown; + +type Reparenting = 'move-back' | 'none'; +type NgElement = HTMLElement & { ngElementStrategy?: { parentElement?: WeakRef } }; + +type PortalSlot = RendererRequest & { + callback?: RenderProp; + /** Boxed: `reason` may be `undefined`. */ + failure?: { reason: unknown }; + portal?: React.ReactPortal; +}; interface WrapperOptions extends Options { @@ -63,6 +82,222 @@ 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 _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 { + const previous = this._slots.get(req.slotName); + + this._slots.set(req.slotName, { + ...req, + data: withDataContext(req.data), + // Keep the old content until the new one renders. + portal: previous?.node === req.node ? previous.portal : undefined, + }); + } + + this._notify(); + }; + + private _collect( + props: Props, + renderers: Renderers, + callbacks: Map, + prefix = '', + ): Props { + const out: Props = {}; + + for (const prop in props) { + const path = prefix ? `${prefix}.${prop}` : 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; + } + } + + 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; + } + + this._invoke(slot, callback); + + if (slot.failure) { + throw slot.failure.reason; + } + + if (slot.portal) { + portals.push(slot.portal); + } + } + + return portals; + } + + /** + * New render prop re-invokes: it may close over state the element can't see. + * Same one keeps its portal; a fresh portal would re-commit and loop. + */ + private _invoke(slot: PortalSlot, callback: RenderProp): void { + if (slot.callback === callback) { + return; + } + + const { node, slotName } = slot; + const content = callback(slot.data); + + slot.callback = callback; + slot.failure = undefined; + + if (!isThenable(content)) { + slot.portal = createPortal(content, node, slotName); + return; + } + + // No Suspense: inline render props yield a new promise per render. + const settle = (patch: Partial) => { + if (this._slots.get(slotName) !== slot || slot.callback !== callback) { + return; + } + + Object.assign(slot, patch); + this._notify(); + }; + + content.then( + (resolved) => settle({ portal: createPortal(resolved, node, slotName) }), + (reason: unknown) => settle({ failure: { reason } }), + ); + } +} + +function useTemplateBridge(react: ReactModule, renderers: Renderers): TemplateBridge { + const [, forceUpdate] = react.useReducer((n: number) => n + 1, 0); + const [bridge] = react.useState(() => new TemplateBridge(renderers, 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 +311,53 @@ 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'; - 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); + const [elementRef, setRef] = useForwardedRef(React, ref); + useReparenting(React, reparenting, elementRef); - processProps(props, renderProps ?? {}, outProps); + const { props: elementProps, portals } = bridge.resolve(props as Props); + const { children } = props as { children?: React.ReactNode }; - 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), - }); - } + elementProps.children = [...React.Children.toArray(children), ...portals]; 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], - ), + ...elementProps, + ref: setRef, } as PropsWithoutRef> & React.RefAttributes); }); }; -function createPatched(callback: (req: RendererRequest) => void, propertyName: string) { - return (ctx: unknown) => html`${requestRenderer(callback, propertyName, ctx)}`; +function isThenable(value: unknown): value is PromiseLike { + return isObject(value) && typeof value.then === 'function'; } -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 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..afa1131 --- /dev/null +++ b/tests/grid-lite/Templates.spec.tsx @@ -0,0 +1,72 @@ +import { expect, test, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { AsyncTemplate, FailingTemplate, OptionalTemplate, StatefulTemplate } from './Templates'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +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 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('an async render prop that rejects reaches the error boundary', async () => { + render(); + + await expect.element(page.getByText('Caught:boom', { exact: true })).toBeVisible(); +}); diff --git a/tests/grid-lite/Templates.tsx b/tests/grid-lite/Templates.tsx new file mode 100644 index 0000000..daca8e1 --- /dev/null +++ b/tests/grid-lite/Templates.tsx @@ -0,0 +1,117 @@ +import { Component, type ComponentProps, type ReactNode, 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 ( + <> + + + + ); +} + +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; + } +} + +export function FailingTemplate() { + const cellTemplate = async () => { + throw new Error('boom'); + }; + + return ( + + + + ); +} 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..21fd358 100644 --- a/tests/web-components/Chat.tsx +++ b/tests/web-components/Chat.tsx @@ -1,3 +1,5 @@ +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'; @@ -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 ( + <> + + + + ); +}