Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions docs/guide/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
Expand Down
25 changes: 21 additions & 4 deletions src/component/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -188,7 +189,7 @@ const createComponentClass = <
value: ComponentStateShape<TState>[TKey]
): void {
this.state[key] = value;
this.render(true);
this.render(true, this.cloneProps());
}

/**
Expand Down Expand Up @@ -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<string, unknown>) } 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;
}

Expand Down
70 changes: 42 additions & 28 deletions src/component/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/component/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ type ComponentHookWithProps<
TState extends Record<string, unknown> | undefined = undefined,
TResult = void,
> = {
(this: ComponentElement<TState>, props: TProps): TResult;
(props: TProps): TResult;
(this: ComponentElement<TState>, newProps: TProps, oldProps: TProps): TResult;
(newProps: TProps, oldProps: TProps): TResult;
};
type ComponentUpdatedHook<
TState extends Record<string, unknown> | undefined = undefined,
Expand Down
57 changes: 52 additions & 5 deletions tests/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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();
});
Expand Down Expand Up @@ -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 });
Expand Down
Loading