diff --git a/README.md b/README.md index f02ec5cf..2d8708c5 100644 --- a/README.md +++ b/README.md @@ -264,8 +264,8 @@ component('user-card', { connected() { console.log('Mounted'); }, - beforeUpdate(props) { - return props.username !== ''; + beforeUpdate(newProps, oldProps) { + return newProps.username !== oldProps.username; }, onError(error) { console.error('Component error:', error); diff --git a/docs/guide/components.md b/docs/guide/components.md index 1195a1e9..68b45c35 100644 --- a/docs/guide/components.md +++ b/docs/guide/components.md @@ -155,7 +155,7 @@ el.setState('clicks', 1); - `beforeMount()` – runs before the element renders (can modify initial state) - `connected()` – runs when the element mounts -- `beforeUpdate(props)` – runs before re-render; return `false` to prevent update +- `beforeUpdate(newProps, oldProps)` – runs before re-render; return `false` to prevent update - `updated()` – runs after re-render on prop changes - `disconnected()` – runs on teardown - `onError(error)` – handles errors during lifecycle/render @@ -169,9 +169,10 @@ component('my-element', { connected() { console.log('Mounted'); }, - beforeUpdate(props) { - // Prevent update if count is negative - if (props.count < 0) return false; + beforeUpdate(newProps, oldProps) { + // Prevent update if count is negative, and skip no-op updates + if (newProps.count < 0) return false; + return newProps.count !== oldProps.count; }, updated() { console.log('Updated'); diff --git a/src/component/component.ts b/src/component/component.ts index 906e5597..60f9869f 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -150,11 +150,12 @@ const createComponentClass = < newValue: string | null ): void { try { + const previousProps = this.cloneProps(); this.syncProps(); if (this.hasMounted) { // Component already mounted - trigger update render - this.render(true, { name, oldValue, newValue }); + this.render(true, previousProps, { name, oldValue, newValue }); } else if (this.isConnected && this.missingRequiredProps.size === 0) { // All required props are now satisfied and element is connected // Trigger the deferred initial mount @@ -188,7 +189,7 @@ const createComponentClass = < value: ComponentStateShape[TKey] ): void { this.state[key] = value; - this.render(true); + this.render(true, this.cloneProps()); } /** @@ -244,14 +245,30 @@ const createComponentClass = < } } + /** + * Creates a shallow snapshot of the current props for lifecycle diffing. + * A shallow copy is sufficient because component props are re-derived from + * reflected attributes on each update, so nested object mutation is not + * tracked as part of this lifecycle diff. + * @internal + */ + private cloneProps(): TProps { + return { ...(this.props as Record) } as TProps; + } + /** * Renders the component to its shadow root. * @internal */ - private render(triggerUpdated = false, change?: AttributeChange): void { + private render(): void; + private render(triggerUpdated: true, oldProps: TProps, change?: AttributeChange): void; + private render(triggerUpdated = false, oldProps?: TProps, change?: AttributeChange): void { try { if (triggerUpdated && definition.beforeUpdate) { - const shouldUpdate = definition.beforeUpdate.call(this, this.props); + if (!oldProps) { + throw new Error('bQuery component: previous props are required for update renders'); + } + const shouldUpdate = definition.beforeUpdate.call(this, this.props, oldProps); if (shouldUpdate === false) return; } diff --git a/src/component/library.ts b/src/component/library.ts index 757d04f1..1fd1a1c4 100644 --- a/src/component/library.ts +++ b/src/component/library.ts @@ -87,20 +87,25 @@ const storeHandler = (element: HTMLElement, key: string, value: EventListener): handlerStore.set(element, handlers); }; -const getShadowLabelText = (element: HTMLElement): string => { - return element.shadowRoot?.querySelector('.label')?.textContent ?? ''; -}; - /** * Detect a value-only input update, patch the live control in place, and * return whether the component can skip a full shadow DOM re-render. * * @param element - The host custom element whose shadow DOM is being updated - * @param props - The next reflected input props for the pending update + * @param newProps - The next reflected input props for the pending update + * @param oldProps - The previous reflected input props from the last render */ const canSkipInputRender = ( element: HTMLElement, - props: { + newProps: { + label: string; + type: string; + value: string; + placeholder: string; + name: string; + disabled: boolean; + }, + oldProps: { label: string; type: string; value: string; @@ -109,17 +114,17 @@ const canSkipInputRender = ( disabled: boolean; } ): boolean => { + if (oldProps.label !== newProps.label) return false; + if (oldProps.type !== newProps.type) return false; + if (oldProps.placeholder !== newProps.placeholder) return false; + if (oldProps.name !== newProps.name) return false; + if (oldProps.disabled !== newProps.disabled) return false; + const control = element.shadowRoot?.querySelector('input.control') as HTMLInputElement | null; if (!control) return false; - if (getShadowLabelText(element) !== props.label) return false; - if ((control.getAttribute('type') ?? 'text') !== props.type) return false; - if ((control.getAttribute('placeholder') ?? '') !== props.placeholder) return false; - if ((control.getAttribute('name') ?? '') !== props.name) return false; - if (control.disabled !== props.disabled) return false; - - if (control.value !== props.value) { - control.value = props.value; + if (control.value !== newProps.value) { + control.value = newProps.value; } return true; @@ -130,11 +135,20 @@ const canSkipInputRender = ( * return whether the component can skip a full shadow DOM re-render. * * @param element - The host custom element whose shadow DOM is being updated - * @param props - The next reflected textarea props for the pending update + * @param newProps - The next reflected textarea props for the pending update + * @param oldProps - The previous reflected textarea props from the last render */ const canSkipTextareaRender = ( element: HTMLElement, - props: { + newProps: { + label: string; + value: string; + placeholder: string; + name: string; + rows: number; + disabled: boolean; + }, + oldProps: { label: string; value: string; placeholder: string; @@ -143,19 +157,19 @@ const canSkipTextareaRender = ( disabled: boolean; } ): boolean => { + if (oldProps.label !== newProps.label) return false; + if (oldProps.placeholder !== newProps.placeholder) return false; + if (oldProps.name !== newProps.name) return false; + if (oldProps.rows !== newProps.rows) return false; + if (oldProps.disabled !== newProps.disabled) return false; + const control = element.shadowRoot?.querySelector( 'textarea.control' ) as HTMLTextAreaElement | null; if (!control) return false; - if (getShadowLabelText(element) !== props.label) return false; - if ((control.getAttribute('placeholder') ?? '') !== props.placeholder) return false; - if ((control.getAttribute('name') ?? '') !== props.name) return false; - if (control.getAttribute('rows') !== String(props.rows)) return false; - if (control.disabled !== props.disabled) return false; - - if (control.value !== props.value) { - control.value = props.value; + if (control.value !== newProps.value) { + control.value = newProps.value; } return true; }; @@ -328,8 +342,8 @@ export const registerDefaultComponents = ( * Skip the full shadow DOM re-render when only the reflected input value * changed, because the live control has already been patched in place. */ - beforeUpdate(props) { - if (canSkipInputRender(this, props)) { + beforeUpdate(newProps, oldProps) { + if (canSkipInputRender(this, newProps, oldProps)) { return false; } return true; @@ -399,8 +413,8 @@ export const registerDefaultComponents = ( * Skip the full shadow DOM re-render when only the reflected textarea value * changed, because the live control has already been patched in place. */ - beforeUpdate(props) { - if (canSkipTextareaRender(this, props)) { + beforeUpdate(newProps, oldProps) { + if (canSkipTextareaRender(this, newProps, oldProps)) { return false; } return true; diff --git a/src/component/types.ts b/src/component/types.ts index 2f2fd5fe..900c94d6 100644 --- a/src/component/types.ts +++ b/src/component/types.ts @@ -155,8 +155,8 @@ type ComponentHookWithProps< TState extends Record | undefined = undefined, TResult = void, > = { - (this: ComponentElement, props: TProps): TResult; - (props: TProps): TResult; + (this: ComponentElement, newProps: TProps, oldProps: TProps): TResult; + (newProps: TProps, oldProps: TProps): TResult; }; type ComponentUpdatedHook< TState extends Record | undefined = undefined, diff --git a/tests/component.test.ts b/tests/component.test.ts index 327d3d2d..7be59ef8 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -282,17 +282,17 @@ describe('component/component', () => { el.remove(); }); - it('calls beforeUpdate before re-renders and receives props', () => { + it('calls beforeUpdate before re-renders and receives new and previous props', () => { const tagName = `test-before-update-${Date.now()}`; - const receivedProps: unknown[] = []; + const receivedProps: Array<{ newProps: { count: number }; oldProps: { count: number } }> = []; let renderCount = 0; component<{ count: number }>(tagName, { props: { count: { type: Number, default: 0 }, }, - beforeUpdate(props) { - receivedProps.push({ ...props }); + beforeUpdate(newProps, oldProps) { + receivedProps.push({ newProps: { ...newProps }, oldProps: { ...oldProps } }); return true; // allow update }, render: ({ props }) => { @@ -312,7 +312,10 @@ describe('component/component', () => { expect(renderCount).toBe(2); expect(receivedProps).toHaveLength(1); - expect(receivedProps[0]).toEqual({ count: 10 }); + expect(receivedProps[0]).toEqual({ + newProps: { count: 10 }, + oldProps: { count: 0 }, + }); el.remove(); }); @@ -1335,6 +1338,50 @@ describe('component/registerDefaultComponents', () => { textarea.remove(); }); + it('re-renders input and textarea controls when non-value props change', () => { + const prefix = `rerender${Date.now()}`; + const tags = registerDefaultComponents({ prefix }); + + const input = document.createElement(tags.input); + input.setAttribute('label', 'Name'); + document.body.appendChild(input); + + const inputControl = input.shadowRoot?.querySelector('input') as HTMLInputElement | null; + expect(inputControl).not.toBeNull(); + if (!inputControl) throw new Error('Expected input control to exist'); + + input.setAttribute('label', 'Full name'); + + const inputControlAfterLabelUpdate = input.shadowRoot?.querySelector( + 'input' + ) as HTMLInputElement | null; + expect(inputControlAfterLabelUpdate).not.toBeNull(); + expect(inputControlAfterLabelUpdate).not.toBe(inputControl); + expect(input.shadowRoot?.textContent).toContain('Full name'); + + const textarea = document.createElement(tags.textarea); + textarea.setAttribute('label', 'Notes'); + document.body.appendChild(textarea); + + const textareaControl = textarea.shadowRoot?.querySelector( + 'textarea' + ) as HTMLTextAreaElement | null; + expect(textareaControl).not.toBeNull(); + if (!textareaControl) throw new Error('Expected textarea control to exist'); + + textarea.setAttribute('rows', '6'); + + const textareaControlAfterRowsUpdate = textarea.shadowRoot?.querySelector( + 'textarea' + ) as HTMLTextAreaElement | null; + expect(textareaControlAfterRowsUpdate).not.toBeNull(); + expect(textareaControlAfterRowsUpdate).not.toBe(textareaControl); + expect(textareaControlAfterRowsUpdate?.getAttribute('rows')).toBe('6'); + + input.remove(); + textarea.remove(); + }); + it('dispatches a single host event for input, textarea, and checkbox interactions', () => { const prefix = `events${Date.now()}`; const tags = registerDefaultComponents({ prefix });