From 028eb6a66a3282c2693448640804ed0e4a23e256 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 12 Aug 2026 09:03:41 +0300 Subject: [PATCH 1/2] fix(equal): track the pair being compared, not the two objects --- src/equal.ts | 138 ++++++++++++++++++++++++-------------------- tests/equal.spec.ts | 43 ++++++++++++++ 2 files changed, 117 insertions(+), 64 deletions(-) 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/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); + }); }); From 48069f5c073ab30204e3ff3d54058f51246b9149 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 12 Aug 2026 09:05:08 +0300 Subject: [PATCH 2/2] fix(render-props): keep render prop state alive across renders The render prop machinery kept its state in per-render locals while the element held on to the patched templates indefinitely, so the two drifted apart the moment the component re-rendered. Three defects came out of that: * Templates rendered against a stale closure. The patched function handed to the element was cached on first sight - but so was the callback it invoked, so a template closing over React state kept rendering the value it saw on the very first render. * An `undefined` render prop spun the render loop. A conditional prop (`headerTemplate={enabled ? tpl : undefined}`) was still treated as a template: it installed an empty portal and recreated the patched function on every render. Each new identity re-rendered the element, which re-requested the template, at roughly 97 cycles a second. The churn also blanked sibling templates on the same component. * Portal updates were lost. The map of active slots was cloned per render, so a request arriving from the element mutated its own copy and a later render overwrote it. That state now lives on a `TemplateBridge` owned by the component instance - patched templates keyed by prop path, current callbacks keyed by renderer name, the slots, and the nested prop containers. Callbacks are refreshed on every render while the patched templates keep their identity, so the element sees a stable prop and the template always runs the current closure. Portals are built when the element requests a slot, and rebuilt only when the render prop's identity changes. Pruning is driven by a single predicate - is there still a function at this prop path? - which covers both a removed prop and one that turned `undefined`. It also drops the matching slots: the directive's remove request travels through a `WeakRef` and may never arrive once the patched template is gone, leaving the portal to linger for the component's lifetime. Nested prop containers reuse their previous object while shallow-equal, so a config object carrying a template stops changing identity every render. The rest of the file follows the same split. Ref forwarding and the Angular re-parenting effect move into `useForwardedRef` and `useReparenting`, leaving `createComponent` with registration and a nine-line component. In `render-props.ts`: `_renderNode` could return `undefined` behind an `as Element` cast; requests now go through a helper that bails when there is no node. `render()` is typed as `RendererCallback` so `any` stops leaking into `update()`. `_state.previous` resets on every disconnect, not just some. Adds regression tests for the three defects, each confirmed to fail against the previous code. --- src/react-props.tsx | 483 ++++++++++++++++++++--------- src/render-props.ts | 28 +- tests/grid-lite/Templates.spec.tsx | 50 +++ tests/grid-lite/Templates.tsx | 68 ++++ 4 files changed, 475 insertions(+), 154 deletions(-) create mode 100644 tests/grid-lite/Templates.spec.tsx create mode 100644 tests/grid-lite/Templates.tsx 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/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 ( + <> + + + + + + + ); +}