From 3608acd41ccaa3eadfdb68d313997e97405bb684 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:15:49 +0000 Subject: [PATCH 1/4] Initial plan From 7e84769d912a5504da4bea0bcff5c05cec8cbe4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:19:39 +0000 Subject: [PATCH 2/4] Add previous props to beforeUpdate hook Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> --- README.md | 4 +-- docs/guide/components.md | 6 ++-- src/component/component.ts | 17 ++++++--- src/component/library.ts | 70 +++++++++++++++++++++++--------------- src/component/types.ts | 4 +-- tests/component.test.ts | 55 +++++++++++++++++++++++++++--- 6 files changed, 112 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f02ec5cf..ab0c02e9 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 && newProps.username !== ''; }, onError(error) { console.error('Component error:', error); diff --git a/docs/guide/components.md b/docs/guide/components.md index 8800992f..7b052a45 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,9 @@ component('my-element', { connected() { console.log('Mounted'); }, - beforeUpdate(props) { + beforeUpdate(newProps, oldProps) { // Prevent update if count is negative - if (props.count < 0) return false; + if (newProps.count < 0 || newProps.count === oldProps.count) return false; }, updated() { console.log('Updated'); diff --git a/src/component/component.ts b/src/component/component.ts index 81bc1257..164bab5f 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -96,11 +96,12 @@ export const defineComponent = >( _newValue: string | null ): void { try { + const previousProps = this.cloneProps(); this.syncProps(); if (this.hasMounted) { // Component already mounted - trigger update render - this.render(true); + this.render(true, previousProps); } else if (this.isConnected && this.missingRequiredProps.size === 0) { // All required props are now satisfied and element is connected // Trigger the deferred initial mount @@ -131,7 +132,7 @@ export const defineComponent = >( */ setState(key: string, value: unknown): void { this.state[key] = value; - this.render(true); + this.render(true, this.cloneProps()); } /** @@ -183,14 +184,22 @@ export const defineComponent = >( } } + /** + * Creates a shallow snapshot of the current props for lifecycle diffing. + * @internal + */ + private cloneProps(): TProps { + return { ...(this.props as Record) } as TProps; + } + /** * Renders the component to its shadow root. * @internal */ - private render(triggerUpdated = false): void { + private render(triggerUpdated = false, oldProps = this.cloneProps()): void { try { if (triggerUpdated && definition.beforeUpdate) { - const shouldUpdate = definition.beforeUpdate.call(this, this.props); + 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 a6c5de7d..325dd59a 100644 --- a/src/component/types.ts +++ b/src/component/types.ts @@ -62,8 +62,8 @@ export type ComponentRenderContext> = { */ type ComponentHook = ((this: HTMLElement) => TResult) | (() => TResult); type ComponentHookWithProps, TResult = void> = - | ((this: HTMLElement, props: TProps) => TResult) - | ((props: TProps) => TResult); + | ((this: HTMLElement, newProps: TProps, oldProps: TProps) => TResult) + | ((newProps: TProps, oldProps: TProps) => TResult); type ComponentErrorHook = ((this: HTMLElement, error: Error) => void) | ((error: Error) => void); export type ComponentDefinition = Record> = diff --git a/tests/component.test.ts b/tests/component.test.ts index bf193e09..9661d778 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -156,17 +156,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 }) => { @@ -186,7 +186,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(); }); @@ -1078,6 +1081,48 @@ 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); + + 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); + + 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 }); From a74a384f64e58927e989b0f9ec39172d8a6d94bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:23:27 +0000 Subject: [PATCH 3/4] Refine beforeUpdate old props implementation Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> --- README.md | 2 +- docs/guide/components.md | 5 +++-- src/component/component.ts | 10 +++++++++- tests/component.test.ts | 2 ++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ab0c02e9..2d8708c5 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ component('user-card', { console.log('Mounted'); }, beforeUpdate(newProps, oldProps) { - return newProps.username !== oldProps.username && newProps.username !== ''; + 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 7b052a45..de805f0c 100644 --- a/docs/guide/components.md +++ b/docs/guide/components.md @@ -170,8 +170,9 @@ component('my-element', { console.log('Mounted'); }, beforeUpdate(newProps, oldProps) { - // Prevent update if count is negative - if (newProps.count < 0 || newProps.count === oldProps.count) return false; + // 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 164bab5f..c89e4bef 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -186,6 +186,9 @@ export const defineComponent = >( /** * 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 { @@ -196,9 +199,14 @@ export const defineComponent = >( * Renders the component to its shadow root. * @internal */ - private render(triggerUpdated = false, oldProps = this.cloneProps()): void { + private render(): void; + private render(triggerUpdated: true, oldProps: TProps): void; + private render(triggerUpdated = false, oldProps?: TProps): void { try { if (triggerUpdated && definition.beforeUpdate) { + 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/tests/component.test.ts b/tests/component.test.ts index 9661d778..b135e1f1 100644 --- a/tests/component.test.ts +++ b/tests/component.test.ts @@ -1100,6 +1100,7 @@ describe('component/registerDefaultComponents', () => { ) 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'); @@ -1118,6 +1119,7 @@ describe('component/registerDefaultComponents', () => { ) as HTMLTextAreaElement | null; expect(textareaControlAfterRowsUpdate).not.toBeNull(); expect(textareaControlAfterRowsUpdate).not.toBe(textareaControl); + expect(textareaControlAfterRowsUpdate?.getAttribute('rows')).toBe('6'); input.remove(); textarea.remove(); From d43e3503b0d4785bfd8b078a7ffec52e74ec19ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:46:17 +0000 Subject: [PATCH 4/4] Fix beforeUpdate typing regressions Co-authored-by: JosunLP <20913954+JosunLP@users.noreply.github.com> --- src/component/component.ts | 7 +++---- src/component/types.ts | 9 ++------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/component/component.ts b/src/component/component.ts index 3c9e1667..60f9869f 100644 --- a/src/component/component.ts +++ b/src/component/component.ts @@ -155,7 +155,7 @@ const createComponentClass = < 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 @@ -261,9 +261,8 @@ const createComponentClass = < * @internal */ private render(): void; - private render(triggerUpdated: true, oldProps: TProps): void; - private render(triggerUpdated = false, oldProps?: TProps): void; - private render(triggerUpdated = false, change?: AttributeChange): void { + private render(triggerUpdated: true, oldProps: TProps, change?: AttributeChange): void; + private render(triggerUpdated = false, oldProps?: TProps, change?: AttributeChange): void { try { if (triggerUpdated && definition.beforeUpdate) { if (!oldProps) { diff --git a/src/component/types.ts b/src/component/types.ts index 4321800a..900c94d6 100644 --- a/src/component/types.ts +++ b/src/component/types.ts @@ -142,11 +142,6 @@ export type AttributeChange = { * Arrow functions capture outer scope, so component APIs like `this.getState()` * are only available from method/function syntax. */ -type ComponentHook = ((this: HTMLElement) => TResult) | (() => TResult); -type ComponentHookWithProps, TResult = void> = - | ((this: HTMLElement, newProps: TProps, oldProps: TProps) => TResult) - | ((newProps: TProps, oldProps: TProps) => TResult); -type ComponentErrorHook = ((this: HTMLElement, error: Error) => void) | ((error: Error) => void); type ComponentSanitizeOptions = Pick; type ComponentHook< TState extends Record | undefined = undefined, @@ -160,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,