diff --git a/src/equal.ts b/src/equal.ts index 54492f3..b16ccc6 100644 --- a/src/equal.ts +++ b/src/equal.ts @@ -1,87 +1,97 @@ -export function equal(a: unknown, b: T, visited = new WeakSet()): boolean { +/** Pairs currently being compared, so a cycle terminates without swallowing real differences. */ +type Visited = WeakMap>; + +export function equal(a: unknown, b: T, visited: Visited = new WeakMap()): boolean { // Early return 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; + if (a.constructor !== b.constructor) return false; - // Circular references - if (visited.has(a) && visited.has(b)) { - return true; - } + // Circular references. This tracks the *pair*, not the two objects separately: the Map and Set + // comparisons below probe candidates they expect to fail, and marking those objects on their own + // would make a later comparison of the same pair short circuit to `true`. + let pending = visited.get(a); + if (pending?.has(b)) return true; + + if (!pending) { + pending = new WeakSet(); + visited.set(a, pending); + } + pending.add(b); + + try { + return compare(a, b, visited); + } finally { + // Always release the pair, including on the early returns in `compare`. + pending.delete(b); + } +} - 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; - } +function compare(a: object, b: object, visited: Visited): boolean { + // 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; + if (!found) return false; } + return true; + } - // 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; - } + // 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 (!found) return false; } + return true; + } - // 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; - } - return true; + // 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; } + 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(); - - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - - for (const key of aKeys) { - if (!Object.hasOwn(b, key)) return false; - } + // 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(); - for (const key of aKeys) { - if (!equal(a[key as keyof typeof a], b[key as keyof typeof b], visited)) return false; - } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + 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 { diff --git a/src/react-props.tsx b/src/react-props.tsx index c457c9f..8f4510d 100644 --- a/src/react-props.tsx +++ b/src/react-props.tsx @@ -36,12 +36,21 @@ type ComponentProps = Omit< EventListeners & ElementProps; -/** Mapped type to update the render props callback return type */ -type WithJsxRenderProps = { +/** + * Mapped type to update the render props callback return type. + * + * A renderer entry is either a name (a template directly on the component) or a nested map of + * them (a template on a config object, as `igc-chat` declares its renderers), so it recurses. + */ +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,14 +64,295 @@ export type ReactWebComponent< PropsWithoutRef, R>> & React.RefAttributes >; +/** A map of prop names to renderer names, nested to mirror the shape of the props themselves. */ type Renderers = Record; +/** The React module, which is injected rather than imported so Preact can be swapped in. */ +type ReactModule = typeof React; + +type Props = Record; +type RenderProp = (data: unknown) => React.ReactNode; + +/** A patched template handed to the element, and the renderer it stands in for. */ +type PatchedTemplate = { + patched: (ctx: unknown) => unknown; + rendererName: string; +}; + +/** A slot the element has asked us to fill, and the portal currently filling it. */ +type PortalSlot = { + name: string; + data: unknown; + node: Element; + callback: RenderProp | undefined; + portal: React.ReactPortal; +}; + interface WrapperOptions extends Options { renderProps?: R; moveBackOnDelete?: boolean; } +/** + * Owns the render prop machinery of a single component instance. + * + * All of this state has to outlive the render that created it: the element holds on to the patched + * templates indefinitely and can invoke them whenever it likes, so what they read and write belongs + * to the instance rather than to a render. + */ +class TemplateBridge { + /** Patched templates handed to the element, keyed by their full prop path. */ + private readonly _templates = new Map(); + + /** The *current* render prop callbacks, keyed by renderer name. */ + private readonly _callbacks = new Map(); + + /** Slots the element has asked us to fill, keyed by slot name. */ + private readonly _slots = new Map(); + + /** Nested prop containers, kept around so their identity is stable while their contents are. */ + private readonly _containers = new Map(); + + private readonly _renderers: Renderers; + private readonly _notify: () => void; + + constructor(renderers: Renderers, notify: () => void) { + this._renderers = renderers; + this._notify = notify; + } + + /** + * Turns the component props into the props handed to the element, swapping every render prop for + * a patched template of stable identity. + */ + public resolve(props: Props): Props { + const elementProps: Props = {}; + + this._prune(props); + this._collect(props, this._renderers, elementProps); + this._refresh(); + + return elementProps; + } + + /** The portals currently filling the element's slots. */ + public *portals(): Generator { + for (const { portal } of this._slots.values()) { + yield portal; + } + } + + /** + * Fills or clears a slot at the element's request. Bound once per instance, since the patched + * templates hold on to it for as long as the element does. + */ + private readonly _request = (req: RendererRequest): void => { + if (req.data === REQUEST_REMOVE) { + this._slots.delete(req.slotName); + } else { + const callback = this._callbacks.get(req.name); + + this._slots.set(req.slotName, { + name: req.name, + data: req.data, + node: req.node, + callback, + portal: createPortal(callback?.(req.data), req.node, req.slotName), + }); + } + + this._notify(); + }; + + /** + * Drops patched templates whose render prop is gone - either the prop was removed, or it is no + * longer a function. Both have to be pruned: leaving one behind hands the element a stale + * template, while recreating one on every render feeds it a new identity and spins the loop. + */ + private _prune(props: Props): void { + for (const [path, { rendererName }] of this._templates) { + if (typeof getAtPath(props, path) === 'function') { + continue; + } + + this._templates.delete(path); + this._callbacks.delete(rendererName); + + // Drop the slots as well. The directive does emit a remove request when it disconnects, but + // it reaches for the callback through a `WeakRef` - once the patched template above is gone + // that request may never arrive, and the portal would linger for the component's lifetime. + for (const [slotName, slot] of this._slots) { + if (slot.name === rendererName) { + this._slots.delete(slotName); + } + } + } + } + + /** Copies the props over, swapping render props for templates and recursing into config objects. */ + private _collect(props: Props, renderers: Renderers, out: Props, prefix = ''): void { + for (const prop in props) { + const path = prefix ? `${prefix}.${prop}` : prop; + const renderer = renderers[prop]; + const value = props[prop]; + + if (typeof renderer === 'string') { + out[prop] = this._template(path, renderer, value); + } else if (isRecord(renderer) && isRecord(value)) { + const nested: Props = {}; + + this._collect(value, renderer, nested, path); + out[prop] = this._container(path, nested); + } else { + out[prop] = value; + } + } + } + + /** + * The patched template standing in for a render prop, created on first sight. + * + * Only a function is a template. Anything else - most often `undefined` from a conditional prop - + * has to reach the element untouched so it can fall back to its own default rendering. + */ + private _template(path: string, name: string, value: unknown): unknown { + if (typeof value !== 'function') { + return value; + } + + // Refreshed on every render. The patched template is cached so the element sees a stable prop, + // but the callback it invokes must always be the current one, or the template renders against + // a stale closure. + this._callbacks.set(name, value as RenderProp); + + let template = this._templates.get(path); + + if (!template) { + template = { patched: createPatched(this._request, name), rendererName: name }; + this._templates.set(path, template); + } + + return template.patched; + } + + /** Reuses the previous container while its contents are unchanged, to keep its identity stable. */ + 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; + } + + /** + * A portal is built when the element requests its slot and reused as-is afterwards - handing + * React a fresh portal on every render would re-commit the template into the element, which + * re-renders the element, which requests the template again. + * + * It does have to be rebuilt when the render prop itself changes, though. Templates commonly + * close over state, and the element has no reason to re-request one just because the React tree + * above it re-rendered. + */ + private _refresh(): void { + for (const [slotName, slot] of this._slots) { + const callback = this._callbacks.get(slot.name); + + if (slot.callback !== callback) { + slot.callback = callback; + slot.portal = createPortal(callback?.(slot.data), slot.node, slotName); + } + } + } +} + +/** Creates the render prop state of this component instance and keeps it for its lifetime. */ +function useTemplateBridge(react: ReactModule, renderers: Renderers): TemplateBridge { + const [, forceUpdate] = react.useReducer(increment, 0); + const bridge = react.useRef(null); + + // `forceUpdate` is stable for the lifetime of the component, so capturing the first one is safe. + bridge.current ??= new TemplateBridge(renderers, forceUpdate); + + return bridge.current; +} + +/** Forwards the ref while keeping a local handle on the element for the hooks that need one. */ +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]; +} + +/** + * Handles element re-parenting for the Angular integration, where Angular Elements moves a + * projected element away from the parent React knows about. + */ +function useReparenting( + react: ReactModule, + enabled: boolean | undefined, + elementRef: React.RefObject, +): void { + const projectionParent = react.useRef | null>(null); + + // 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. + react.useLayoutEffect(() => { + if (!enabled) { + 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); + } + }; + }, [enabled, elementRef]); +} + export const createComponent = < I extends HTMLElement, E extends EventNames = {}, @@ -81,152 +371,37 @@ export const createComponent = < (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; + if (!renderProps && !moveBackOnDelete) { + // When R is empty (no renderProps), the component types are equivalent at runtime + return component as unknown as ReactWebComponent; + } - 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)); - }; + const renderers: Renderers = renderProps ?? {}; - 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]; - } - } - }; + type PropsWithRenderProps = WithJsxRenderProps, R>; - for (const key of listeners.current.keys()) { - if (!hasNestedProperty(props, key)) { - listeners.current.delete(key); - } - } + return React.forwardRef((props, ref) => { + const bridge = useTemplateBridge(React, renderers); + const [elementRef, setRef] = useForwardedRef(React, ref); + useReparenting(React, moveBackOnDelete, elementRef); - processProps(props, renderProps ?? {}, outProps); + const elementProps = bridge.resolve(props as Props); + const children = React.Children.toArray((props as { children?: React.ReactNode }).children); - 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), - }); - } + children.push(...bridge.portals()); + elementProps.children = children; 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); }); }; @@ -235,14 +410,34 @@ function createPatched(callback: (req: RendererRequest) => void, proper return (ctx: unknown) => html`${requestRenderer(callback, propertyName, ctx)}`; } -function hasNestedProperty(object: Record, path: string): boolean { - const parts = path.split('.'); +function increment(count: number): number { + return count + 1; +} + +function isRecord(value: unknown): value is Props { + return typeof value === 'object' && value !== null; +} + +function shallowEqual(a: Props, b: Props): boolean { + const keys = Object.keys(a); + + if (keys.length !== Object.keys(b).length) { + return false; + } + + return keys.every((key) => Object.is(a[key], b[key])); +} + +/** Resolves a dot delimited path, returning `undefined` if any segment is missing. */ +function getAtPath(object: Props, path: string): unknown { let current: unknown = object; - for (const part of parts) { - if (current === undefined || current === null || typeof current !== 'object') { - return false; + + for (const part of path.split('.')) { + if (!isRecord(current)) { + return undefined; } - current = (current as Record)[part]; + current = current[part]; } - return current !== undefined; + + return current; } diff --git a/src/render-props.ts b/src/render-props.ts index edfbb3c..725bf6a 100644 --- a/src/render-props.ts +++ b/src/render-props.ts @@ -12,7 +12,7 @@ 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 NgState = T & { implicit: unknown }; type RendererState = { previous: T; current: T; @@ -49,8 +49,8 @@ class RequestRenderer extends AsyncDirective { private _state = { previous: NOT_SET, current: undefined } as RendererState; private _name!: string; - private get _renderNode(): Element { - return this._part?.deref()?.parentNode as Element; + private get _renderNode(): Element | undefined { + return this._part?.deref()?.parentNode as Element | undefined; } private _shouldUpdateNG(_data: NgState): boolean { @@ -68,7 +68,7 @@ class RequestRenderer extends AsyncDirective { private _shouldUpdate(): boolean { const data = this._state.current; - if (Reflect.has(data as NgState, 'implicit')) { + if (data !== null && typeof data === 'object' && Reflect.has(data as NgState, 'implicit')) { return this._shouldUpdateNG(data as NgState); } @@ -80,7 +80,15 @@ class RequestRenderer extends AsyncDirective { return true; } - public override render(_callback: any, _name: string, _data: T): symbol { + /** Dispatches a request for the current state, if there is somewhere to render it. */ + private _request(callback: RendererCallback, data: T | typeof REQUEST_REMOVE): void { + const node = this._renderNode; + if (!node) return; + + callback(createRequestData(this._name, data, node, this._key)); + } + + public override render(_callback: RendererCallback, _name: string, _data: T): symbol { return noChange; } @@ -94,7 +102,7 @@ class RequestRenderer extends AsyncDirective { this._part = new WeakRef(part); if (this.isConnected && callback && this._shouldUpdate()) { - callback(createRequestData(this._name, this._state.current, this._renderNode, this._key)); + this._request(callback, this._state.current); } return noChange; @@ -103,17 +111,17 @@ class RequestRenderer extends AsyncDirective { 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._request(callback, this._state.current); } } 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); } + // drop prev, so a reconnect would behave like initial + this._state.previous = NOT_SET as T; } } diff --git a/tests/equal.spec.ts b/tests/equal.spec.ts index 6157515..8d4f1fa 100644 --- a/tests/equal.spec.ts +++ b/tests/equal.spec.ts @@ -337,4 +337,47 @@ 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, so these Sets differ. Getting there walks candidate pairs + // that fail, and those failures must not be remembered as matches. + 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', () => { + const a = new Map([ + ['a', { v: 1 }], + ['b', { v: 2 }], + ]); + const b = new Map([ + ['b', { v: 2 }], + ['a', { v: 1 }], + ]); + expect(equal(a, b)).toBe(true); + + const mismatched = new Map([ + ['a', { v: 1 }], + ['b', { v: 3 }], + ]); + expect(equal(a, mismatched)).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..09eb960 --- /dev/null +++ b/tests/grid-lite/Templates.spec.tsx @@ -0,0 +1,50 @@ +import { expect, test } from 'vitest'; +import { page } from 'vitest/browser'; +import { render } from 'vitest-browser-react'; +import { 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(); + + // the template closes over `count` - it must re-render with the new value + 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 leaves its siblings intact', async () => { + render(); + + await expect.element(page.getByText('V:1', { exact: true })).toBeVisible(); + await expect.element(page.getByText('V:2', { exact: true })).toBeVisible(); +}); + +test('a render prop passed as undefined does not spin the render loop', async () => { + render(); + + const cell = page.getByText('V:1', { exact: true }); + await expect.element(cell).toBeVisible(); + + // portals are torn down and recreated on every cycle, so a settled component + // keeps the same DOM node + const node = cell.element(); + await delay(300); + expect(cell.element()).toBe(node); +}); + +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(); +}); diff --git a/tests/grid-lite/Templates.tsx b/tests/grid-lite/Templates.tsx new file mode 100644 index 0000000..d16d786 --- /dev/null +++ b/tests/grid-lite/Templates.tsx @@ -0,0 +1,68 @@ +import { useMemo, 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' }, +]; + +/** A cell template closing over React state. */ +export function StatefulTemplate() { + const [count, setCount] = useState(0); + const records = useMemo(() => data, []); + + const cellTemplate = (ctx: IgrCellContext) => ( + + V:{ctx.value}/C:{count} + + ); + + return ( + <> + + + + + + + ); +} + +/** A render prop that is conditionally `undefined`, next to one that is always set. */ +export function OptionalTemplate() { + const [withHeader, setWithHeader] = useState(false); + const records = useMemo(() => data, []); + + const cellTemplate = (ctx: IgrCellContext) => V:{ctx.value}; + const headerTemplate = (ctx: IgrHeaderContext) => H:{ctx.column.field}; + + return ( + <> + + + + + + + ); +}