diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index b339b919a50..8903ea2e0bf 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.15.0 + +- [BC BREAK] The `data-live-id` attribute was changed to `id`. + ## 2.14.1 - Fixed a regression in the testing tools related to the default HTTP @@ -7,8 +11,10 @@ ## 2.14.0 +- [BC BREAK] DOM morphing changed from `morphdom` to `idiomorph`. As this is + a different morphing library, there may be some edge cases where the + morphing behavior is different. - Add support for URL binding in `LiveProp` -- DOM morphing changed from `morphdom` to `idiomorph` - Allow multiple `LiveListener` attributes on a single method - Requests to LiveComponent are sent as POST by default - Add method prop to AsLiveComponent to still allow GET requests, usage: `#[AsLiveComponent(method: 'get')]` diff --git a/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts b/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts index 972773de068..776f4701ee0 100644 --- a/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts +++ b/src/LiveComponent/assets/dist/Component/ElementDriver.d.ts @@ -1,31 +1,30 @@ +import LiveControllerDefault from '../live_controller'; export interface ElementDriver { getModelName(element: HTMLElement): string | null; - getComponentProps(rootElement: HTMLElement): any; - findChildComponentElement(id: string, element: HTMLElement): HTMLElement | null; - getKeyFromElement(element: HTMLElement): string | null; - getEventsToEmit(element: HTMLElement): Array<{ + getComponentProps(): any; + getEventsToEmit(): Array<{ event: string; data: any; target: string | null; componentName: string | null; }>; - getBrowserEventsToDispatch(element: HTMLElement): Array<{ + getBrowserEventsToDispatch(): Array<{ event: string; payload: any; }>; } -export declare class StandardElementDriver implements ElementDriver { +export declare class StimulusElementDriver implements ElementDriver { + private readonly controller; + constructor(controller: LiveControllerDefault); getModelName(element: HTMLElement): string | null; - getComponentProps(rootElement: HTMLElement): any; - findChildComponentElement(id: string, element: HTMLElement): HTMLElement | null; - getKeyFromElement(element: HTMLElement): string | null; - getEventsToEmit(element: HTMLElement): Array<{ + getComponentProps(): any; + getEventsToEmit(): Array<{ event: string; data: any; target: string | null; componentName: string | null; }>; - getBrowserEventsToDispatch(element: HTMLElement): Array<{ + getBrowserEventsToDispatch(): Array<{ event: string; payload: any; }>; diff --git a/src/LiveComponent/assets/dist/Component/index.d.ts b/src/LiveComponent/assets/dist/Component/index.d.ts index 7470b6e9da7..9c8b44ee2eb 100644 --- a/src/LiveComponent/assets/dist/Component/index.d.ts +++ b/src/LiveComponent/assets/dist/Component/index.d.ts @@ -3,17 +3,14 @@ import ValueStore from './ValueStore'; import { ElementDriver } from './ElementDriver'; import { PluginInterface } from './plugins/PluginInterface'; import BackendResponse from '../Backend/BackendResponse'; -import { ModelBinding } from '../Directive/get_model_binding'; -export type ComponentFinder = (currentComponent: Component, onlyParents: boolean, onlyMatchName: string | null) => Component[]; export default class Component { readonly element: HTMLElement; readonly name: string; readonly listeners: Map; - private readonly componentFinder; private backend; - private readonly elementDriver; + readonly elementDriver: ElementDriver; id: string | null; - fingerprint: string | null; + fingerprint: string; readonly valueStore: ValueStore; private readonly unsyncedInputsTracker; private hooks; @@ -25,14 +22,11 @@ export default class Component { private requestDebounceTimeout; private nextRequestPromise; private nextRequestPromiseResolve; - private children; - private parent; private externalMutationTracker; constructor(element: HTMLElement, name: string, props: any, listeners: Array<{ event: string; action: string; - }>, componentFinder: ComponentFinder, fingerprint: string | null, id: string | null, backend: BackendInterface, elementDriver: ElementDriver); - _swapBackend(backend: BackendInterface): void; + }>, id: string | null, backend: BackendInterface, elementDriver: ElementDriver); addPlugin(plugin: PluginInterface): void; connect(): void; disconnect(): void; @@ -44,17 +38,11 @@ export default class Component { files(key: string, input: HTMLInputElement): void; render(): Promise; getUnsyncedModels(): string[]; - addChild(child: Component, modelBindings?: ModelBinding[]): void; - removeChild(child: Component): void; - getParent(): Component | null; - getChildren(): Map; emit(name: string, data: any, onlyMatchingComponentsNamed?: string | null): void; emitUp(name: string, data: any, onlyMatchingComponentsNamed?: string | null): void; emitSelf(name: string, data: any): void; private performEmit; private doEmit; - updateFromNewElementFromParentRender(toEl: HTMLElement): boolean; - onChildComponentModelUpdate(modelName: string, value: any, childComponent: Component): void; private isTurboEnabled; private tryStartingRequest; private performRequest; @@ -63,7 +51,7 @@ export default class Component { private clearRequestDebounceTimeout; private debouncedStartRequest; private renderError; - private getChildrenFingerprints; private resetPromise; + _updateFromParentProps(props: any): void; } export declare function proxifyComponent(component: Component): Component; diff --git a/src/LiveComponent/assets/dist/Component/plugins/ChildComponentPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/ChildComponentPlugin.d.ts new file mode 100644 index 00000000000..f5df0e82b05 --- /dev/null +++ b/src/LiveComponent/assets/dist/Component/plugins/ChildComponentPlugin.d.ts @@ -0,0 +1,11 @@ +import Component from '../../Component'; +import { PluginInterface } from './PluginInterface'; +export default class implements PluginInterface { + private readonly component; + private parentModelBindings; + constructor(component: Component); + attachToComponent(component: Component): void; + private getChildrenFingerprints; + private notifyParentModelChange; + private getChildren; +} diff --git a/src/LiveComponent/assets/dist/ComponentRegistry.d.ts b/src/LiveComponent/assets/dist/ComponentRegistry.d.ts index e9778dd6dd5..d11954758be 100644 --- a/src/LiveComponent/assets/dist/ComponentRegistry.d.ts +++ b/src/LiveComponent/assets/dist/ComponentRegistry.d.ts @@ -1,9 +1,8 @@ import Component from './Component'; -export default class { - private componentMapByElement; - private componentMapByComponent; - registerComponent(element: HTMLElement, component: Component): void; - unregisterComponent(component: Component): void; - getComponent(element: HTMLElement): Promise; - findComponents(currentComponent: Component, onlyParents: boolean, onlyMatchName: string | null): Component[]; -} +export declare const resetRegistry: () => void; +export declare const registerComponent: (component: Component) => void; +export declare const unregisterComponent: (component: Component) => void; +export declare const getComponent: (element: HTMLElement) => Promise; +export declare const findComponents: (currentComponent: Component, onlyParents: boolean, onlyMatchName: string | null) => Component[]; +export declare const findChildren: (currentComponent: Component) => Component[]; +export declare const findParent: (currentComponent: Component) => Component | null; diff --git a/src/LiveComponent/assets/dist/Util/getElementAsTagText.d.ts b/src/LiveComponent/assets/dist/Util/getElementAsTagText.d.ts new file mode 100644 index 00000000000..dbd343f3e80 --- /dev/null +++ b/src/LiveComponent/assets/dist/Util/getElementAsTagText.d.ts @@ -0,0 +1 @@ +export default function getElementAsTagText(element: HTMLElement): string; diff --git a/src/LiveComponent/assets/dist/dom_utils.d.ts b/src/LiveComponent/assets/dist/dom_utils.d.ts index 5286cd11be1..a897f086539 100644 --- a/src/LiveComponent/assets/dist/dom_utils.d.ts +++ b/src/LiveComponent/assets/dist/dom_utils.d.ts @@ -8,4 +8,3 @@ export declare function getModelDirectiveFromElement(element: HTMLElement, throw export declare function elementBelongsToThisComponent(element: Element, component: Component): boolean; export declare function cloneHTMLElement(element: HTMLElement): HTMLElement; export declare function htmlToElement(html: string): HTMLElement; -export declare function getElementAsTagText(element: HTMLElement): string; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 9bb12a6e398..fa533b60970 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -1,8 +1,8 @@ import { Controller } from '@hotwired/stimulus'; import Component from './Component'; -import ComponentRegistry from './ComponentRegistry'; +import { BackendInterface } from './Backend/Backend'; export { Component }; -export declare const getComponent: (element: HTMLElement) => Promise; +export { getComponent } from './ComponentRegistry'; export interface LiveEvent extends CustomEvent { detail: { controller: LiveController; @@ -17,17 +17,31 @@ export default class LiveControllerDefault extends Controller imple static values: { name: StringConstructor; url: StringConstructor; - props: ObjectConstructor; + props: { + type: ObjectConstructor; + default: {}; + }; + propsUpdatedFromParent: { + type: ObjectConstructor; + default: {}; + }; csrf: StringConstructor; listeners: { type: ArrayConstructor; default: never[]; }; + eventsToEmit: { + type: ArrayConstructor; + default: never[]; + }; + eventsToDispatch: { + type: ArrayConstructor; + default: never[]; + }; debounce: { type: NumberConstructor; default: number; }; - id: StringConstructor; fingerprint: { type: StringConstructor; default: string; @@ -44,11 +58,22 @@ export default class LiveControllerDefault extends Controller imple readonly nameValue: string; readonly urlValue: string; readonly propsValue: any; + propsUpdatedFromParentValue: any; readonly csrfValue: string; readonly listenersValue: Array<{ event: string; action: string; }>; + readonly eventsToEmitValue: Array<{ + event: string; + data: any; + target: string | null; + componentName: string | null; + }>; + readonly eventsToDispatchValue: Array<{ + event: string; + payload: any; + }>; readonly hasDebounceValue: boolean; readonly debounceValue: number; readonly fingerprintValue: string; @@ -59,26 +84,31 @@ export default class LiveControllerDefault extends Controller imple }; }; private proxiedComponent; + private mutationObserver; component: Component; pendingActionTriggerModelElement: HTMLElement | null; private elementEventListeners; private pendingFiles; - static componentRegistry: ComponentRegistry; + static backendFactory: (controller: LiveControllerDefault) => BackendInterface; initialize(): void; connect(): void; disconnect(): void; update(event: any): void; action(event: any): void; - $render(): Promise; emit(event: Event): void; emitUp(event: Event): void; emitSelf(event: Event): void; - private getEmitDirectives; + $render(): Promise; $updateModel(model: string, value: any, shouldRender?: boolean, debounce?: number | boolean): Promise; + propsUpdatedFromParentValueChanged(): void; + fingerprintValueChanged(): void; + private getEmitDirectives; + private createComponent; + private connectComponent; + private disconnectComponent; private handleInputEvent; private handleChangeEvent; private updateModelFromElementEvent; - handleConnectedControllerEvent(event: LiveEvent): void; - handleDisconnectedChildControllerEvent(event: LiveEvent): void; private dispatchEvent; + private onMutations; } diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 5663876c4b1..2968fd823db 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -156,6 +156,90 @@ function normalizeModelName(model) { .join('.')); } +function getElementAsTagText(element) { + return element.innerHTML + ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) + : element.outerHTML; +} + +let componentMapByElement = new WeakMap(); +let componentMapByComponent = new Map(); +const registerComponent = function (component) { + componentMapByElement.set(component.element, component); + componentMapByComponent.set(component, component.name); +}; +const unregisterComponent = function (component) { + componentMapByElement.delete(component.element); + componentMapByComponent.delete(component); +}; +const getComponent = function (element) { + return new Promise((resolve, reject) => { + let count = 0; + const maxCount = 10; + const interval = setInterval(() => { + const component = componentMapByElement.get(element); + if (component) { + clearInterval(interval); + resolve(component); + } + count++; + if (count > maxCount) { + clearInterval(interval); + reject(new Error(`Component not found for element ${getElementAsTagText(element)}`)); + } + }, 5); + }); +}; +const findComponents = function (currentComponent, onlyParents, onlyMatchName) { + const components = []; + componentMapByComponent.forEach((componentName, component) => { + if (onlyParents && (currentComponent === component || !component.element.contains(currentComponent.element))) { + return; + } + if (onlyMatchName && componentName !== onlyMatchName) { + return; + } + components.push(component); + }); + return components; +}; +const findChildren = function (currentComponent) { + const children = []; + componentMapByComponent.forEach((componentName, component) => { + if (currentComponent === component) { + return; + } + if (!currentComponent.element.contains(component.element)) { + return; + } + let foundChildComponent = false; + componentMapByComponent.forEach((childComponentName, childComponent) => { + if (foundChildComponent) { + return; + } + if (childComponent === component) { + return; + } + if (childComponent.element.contains(component.element)) { + foundChildComponent = true; + } + }); + children.push(component); + }); + return children; +}; +const findParent = function (currentComponent) { + let parentElement = currentComponent.element.parentElement; + while (parentElement) { + const component = componentMapByElement.get(parentElement); + if (component) { + return component; + } + parentElement = parentElement.parentElement; + } + return null; +}; + function getValueFromElement(element, valueStore) { if (element instanceof HTMLInputElement) { if (element.type === 'checkbox') { @@ -278,7 +362,7 @@ function elementBelongsToThisComponent(element, component) { return false; } let foundChildComponent = false; - component.getChildren().forEach((childComponent) => { + findChildren(component).forEach((childComponent) => { if (foundChildComponent) { return; } @@ -311,11 +395,6 @@ function htmlToElement(html) { } return child; } -function getElementAsTagText(element) { - return element.innerHTML - ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) - : element.outerHTML; -} const getMultipleCheckboxValue = function (element, currentValues) { const finalValues = [...currentValues]; const value = inputValue(element); @@ -1275,10 +1354,28 @@ function normalizeAttributesForComparison(element) { }); } -function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, getElementValue, childComponents, findChildComponent, getKeyFromElement, externalMutationTracker) { - const childComponentMap = new Map(); - childComponents.forEach((childComponent) => { - childComponentMap.set(childComponent.element, childComponent); +const syncAttributes = function (fromEl, toEl) { + for (let i = 0; i < fromEl.attributes.length; i++) { + const attr = fromEl.attributes[i]; + toEl.setAttribute(attr.name, attr.value); + } +}; +function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, getElementValue, externalMutationTracker) { + const preservedOriginalElements = []; + rootToElement.querySelectorAll('[data-live-preserve]').forEach((newElement) => { + const id = newElement.id; + if (!id) { + throw new Error('The data-live-preserve attribute requires an id attribute to be set on the element'); + } + const oldElement = rootFromElement.querySelector(`#${id}`); + if (!(oldElement instanceof HTMLElement)) { + throw new Error(`The element with id "${id}" was not found in the original HTML`); + } + const clonedOldElement = cloneHTMLElement(oldElement); + preservedOriginalElements.push(oldElement); + oldElement.replaceWith(clonedOldElement); + newElement.removeAttribute('data-live-preserve'); + syncAttributes(newElement, oldElement); }); Idiomorph.morph(rootFromElement, rootToElement, { callbacks: { @@ -1289,15 +1386,6 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, if (fromEl === rootFromElement) { return true; } - let idChanged = false; - if (fromEl.hasAttribute('data-live-id')) { - if (fromEl.getAttribute('data-live-id') !== toEl.getAttribute('data-live-id')) { - for (const child of fromEl.children) { - child.setAttribute('parent-live-id-changed', ''); - } - idChanged = true; - } - } if (fromEl instanceof HTMLElement && toEl instanceof HTMLElement) { if (typeof fromEl.__x !== 'undefined') { if (!window.Alpine) { @@ -1308,10 +1396,6 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, } window.Alpine.morph(fromEl.__x, toEl); } - if (childComponentMap.has(fromEl)) { - const childComponent = childComponentMap.get(fromEl); - return !childComponent.updateFromNewElementFromParentRender(toEl) && idChanged; - } if (externalMutationTracker.wasElementAdded(fromEl)) { fromEl.insertAdjacentElement('afterend', toEl); return false; @@ -1333,11 +1417,7 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, } } } - if (fromEl.hasAttribute('parent-live-id-changed')) { - fromEl.removeAttribute('parent-live-id-changed'); - return true; - } - if (fromEl.hasAttribute('data-skip-morph')) { + if (fromEl.hasAttribute('data-skip-morph') || (fromEl.id && fromEl.id !== toEl.id)) { fromEl.innerHTML = toEl.innerHTML; return true; } @@ -1357,14 +1437,12 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, }, }, }); - childComponentMap.forEach((childComponent, element) => { - var _a; - const childComponentInResult = findChildComponent((_a = childComponent.id) !== null && _a !== void 0 ? _a : '', rootFromElement); - if (null === childComponentInResult || element === childComponentInResult) { - return; + preservedOriginalElements.forEach((oldElement) => { + const newElement = rootFromElement.querySelector(`#${oldElement.id}`); + if (!(newElement instanceof HTMLElement)) { + throw new Error('Missing preserved element'); } - childComponentInResult === null || childComponentInResult === void 0 ? void 0 : childComponentInResult.replaceWith(element); - childComponent.updateFromNewElementFromParentRender(childComponentInResult); + newElement.replaceWith(oldElement); }); } @@ -1816,29 +1894,20 @@ class ExternalMutationTracker { } } -class ChildComponentWrapper { - constructor(component, modelBindings) { - this.component = component; - this.modelBindings = modelBindings; - } -} class Component { - constructor(element, name, props, listeners, componentFinder, fingerprint, id, backend, elementDriver) { + constructor(element, name, props, listeners, id, backend, elementDriver) { + this.fingerprint = ''; this.defaultDebounce = 150; this.backendRequest = null; this.pendingActions = []; this.pendingFiles = {}; this.isRequestPending = false; this.requestDebounceTimeout = null; - this.children = new Map(); - this.parent = null; this.element = element; this.name = name; - this.componentFinder = componentFinder; this.backend = backend; this.elementDriver = elementDriver; this.id = id; - this.fingerprint = fingerprint; this.listeners = new Map(); listeners.forEach((listener) => { var _a; @@ -1853,20 +1922,18 @@ class Component { this.resetPromise(); this.externalMutationTracker = new ExternalMutationTracker(this.element, (element) => elementBelongsToThisComponent(element, this)); this.externalMutationTracker.start(); - this.onChildComponentModelUpdate = this.onChildComponentModelUpdate.bind(this); - } - _swapBackend(backend) { - this.backend = backend; } addPlugin(plugin) { plugin.attachToComponent(this); } connect() { + registerComponent(this); this.hooks.triggerHook('connect', this); this.unsyncedInputsTracker.activate(); this.externalMutationTracker.start(); } disconnect() { + unregisterComponent(this); this.hooks.triggerHook('disconnect', this); this.clearRequestDebounceTimeout(); this.unsyncedInputsTracker.deactivate(); @@ -1919,32 +1986,6 @@ class Component { getUnsyncedModels() { return this.unsyncedInputsTracker.getUnsyncedModels(); } - addChild(child, modelBindings = []) { - if (!child.id) { - throw new Error('Children components must have an id.'); - } - this.children.set(child.id, new ChildComponentWrapper(child, modelBindings)); - child.parent = this; - child.on('model:set', this.onChildComponentModelUpdate); - } - removeChild(child) { - if (!child.id) { - throw new Error('Children components must have an id.'); - } - this.children.delete(child.id); - child.parent = null; - child.off('model:set', this.onChildComponentModelUpdate); - } - getParent() { - return this.parent; - } - getChildren() { - const children = new Map(); - this.children.forEach((childComponent, id) => { - children.set(id, childComponent.component); - }); - return children; - } emit(name, data, onlyMatchingComponentsNamed = null) { return this.performEmit(name, data, false, onlyMatchingComponentsNamed); } @@ -1955,7 +1996,7 @@ class Component { return this.doEmit(name, data); } performEmit(name, data, emitUp, matchingName) { - const components = this.componentFinder(this, emitUp, matchingName); + const components = findComponents(this, emitUp, matchingName); components.forEach((component) => { component.doEmit(name, data); }); @@ -1969,37 +2010,6 @@ class Component { this.action(action, data, 1); }); } - updateFromNewElementFromParentRender(toEl) { - const props = this.elementDriver.getComponentProps(toEl); - if (props === null) { - return false; - } - const isChanged = this.valueStore.storeNewPropsFromParent(props); - const fingerprint = toEl.dataset.liveFingerprintValue; - if (fingerprint !== undefined) { - this.fingerprint = fingerprint; - } - if (isChanged) { - this.render(); - } - return isChanged; - } - onChildComponentModelUpdate(modelName, value, childComponent) { - if (!childComponent.id) { - throw new Error('Missing id'); - } - const childWrapper = this.children.get(childComponent.id); - if (!childWrapper) { - throw new Error('Missing child'); - } - childWrapper.modelBindings.forEach((modelBinding) => { - const childModelName = modelBinding.innerModelName || 'value'; - if (childModelName !== modelName) { - return; - } - this.set(modelBinding.modelName, value, modelBinding.shouldRender, modelBinding.debounce); - }); - } isTurboEnabled() { return typeof Turbo !== 'undefined' && !this.element.closest('[data-turbo="false"]'); } @@ -2020,7 +2030,16 @@ class Component { filesToSend[key] = value.files; } } - this.backendRequest = this.backend.makeRequest(this.valueStore.getOriginalProps(), this.pendingActions, this.valueStore.getDirtyProps(), this.getChildrenFingerprints(), this.valueStore.getUpdatedPropsFromParent(), filesToSend); + const requestConfig = { + props: this.valueStore.getOriginalProps(), + actions: this.pendingActions, + updated: this.valueStore.getDirtyProps(), + children: {}, + updatedPropsFromParent: this.valueStore.getUpdatedPropsFromParent(), + files: filesToSend, + }; + this.hooks.triggerHook('request:started', requestConfig); + this.backendRequest = this.backend.makeRequest(requestConfig.props, requestConfig.actions, requestConfig.updated, requestConfig.children, requestConfig.updatedPropsFromParent, requestConfig.files); this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); this.pendingActions = []; this.valueStore.flushDirtyPropsToPending(); @@ -2084,14 +2103,14 @@ class Component { console.error('There was a problem with the component HTML returned:'); throw error; } - const newProps = this.elementDriver.getComponentProps(newElement); - this.valueStore.reinitializeAllProps(newProps); - const eventsToEmit = this.elementDriver.getEventsToEmit(newElement); - const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(newElement); this.externalMutationTracker.handlePendingChanges(); this.externalMutationTracker.stop(); - executeMorphdom(this.element, newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element) => getValueFromElement(element, this.valueStore), Array.from(this.getChildren().values()), this.elementDriver.findChildComponentElement, this.elementDriver.getKeyFromElement, this.externalMutationTracker); + executeMorphdom(this.element, newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element) => getValueFromElement(element, this.valueStore), this.externalMutationTracker); this.externalMutationTracker.start(); + const newProps = this.elementDriver.getComponentProps(); + this.valueStore.reinitializeAllProps(newProps); + const eventsToEmit = this.elementDriver.getEventsToEmit(); + const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(); Object.keys(modifiedModelValues).forEach((modelName) => { this.valueStore.set(modelName, modifiedModelValues[modelName]); }); @@ -2180,25 +2199,17 @@ class Component { }); modal.focus(); } - getChildrenFingerprints() { - const fingerprints = {}; - this.children.forEach((childComponent) => { - const child = childComponent.component; - if (!child.id) { - throw new Error('missing id'); - } - fingerprints[child.id] = { - fingerprint: child.fingerprint, - tag: child.element.tagName.toLowerCase(), - }; - }); - return fingerprints; - } resetPromise() { this.nextRequestPromise = new Promise((resolve) => { this.nextRequestPromiseResolve = resolve; }); } + _updateFromParentProps(props) { + const isChanged = this.valueStore.storeNewPropsFromParent(props); + if (isChanged) { + this.render(); + } + } } function proxifyComponent(component) { return new Proxy(component, { @@ -2336,7 +2347,10 @@ class Backend { } } -class StandardElementDriver { +class StimulusElementDriver { + constructor(controller) { + this.controller = controller; + } getModelName(element) { const modelDirective = getModelDirectiveFromElement(element, false); if (!modelDirective) { @@ -2344,26 +2358,14 @@ class StandardElementDriver { } return modelDirective.action; } - getComponentProps(rootElement) { - var _a; - const propsJson = (_a = rootElement.dataset.livePropsValue) !== null && _a !== void 0 ? _a : '{}'; - return JSON.parse(propsJson); - } - findChildComponentElement(id, element) { - return element.querySelector(`[data-live-id=${id}]`); + getComponentProps() { + return this.controller.propsValue; } - getKeyFromElement(element) { - return element.dataset.liveId || null; + getEventsToEmit() { + return this.controller.eventsToEmitValue; } - getEventsToEmit(element) { - var _a; - const eventsJson = (_a = element.dataset.liveEmit) !== null && _a !== void 0 ? _a : '[]'; - return JSON.parse(eventsJson); - } - getBrowserEventsToDispatch(element) { - var _a; - const eventsJson = (_a = element.dataset.liveBrowserDispatch) !== null && _a !== void 0 ? _a : '[]'; - return JSON.parse(eventsJson); + getBrowserEventsToDispatch() { + return this.controller.eventsToDispatchValue; } } @@ -2476,7 +2478,7 @@ class LoadingPlugin { } getLoadingDirectives(component, element) { const loadingDirectives = []; - let matchingElements = [...element.querySelectorAll('[data-loading]')]; + let matchingElements = [...Array.from(element.querySelectorAll('[data-loading]'))]; matchingElements = matchingElements.filter((elt) => elementBelongsToThisComponent(elt, component)); if (element.hasAttribute('data-loading')) { matchingElements = [element, ...matchingElements]; @@ -2741,53 +2743,6 @@ function getModelBinding (modelDirective) { }; } -class ComponentRegistry { - constructor() { - this.componentMapByElement = new WeakMap(); - this.componentMapByComponent = new Map(); - } - registerComponent(element, component) { - this.componentMapByElement.set(element, component); - this.componentMapByComponent.set(component, component.name); - } - unregisterComponent(component) { - this.componentMapByElement.delete(component.element); - this.componentMapByComponent.delete(component); - } - getComponent(element) { - return new Promise((resolve, reject) => { - let count = 0; - const maxCount = 10; - const interval = setInterval(() => { - const component = this.componentMapByElement.get(element); - if (component) { - clearInterval(interval); - resolve(component); - } - count++; - if (count > maxCount) { - clearInterval(interval); - reject(new Error(`Component not found for element ${getElementAsTagText(element)}`)); - } - }, 5); - }); - } - findComponents(currentComponent, onlyParents, onlyMatchName) { - const components = []; - this.componentMapByComponent.forEach((componentName, component) => { - if (onlyParents && - (currentComponent === component || !component.element.contains(currentComponent.element))) { - return; - } - if (onlyMatchName && componentName !== onlyMatchName) { - return; - } - components.push(component); - }); - return components; - } -} - function isValueEmpty(value) { if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { return true; @@ -2909,7 +2864,52 @@ class QueryStringPlugin { } } -const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element); +class ChildComponentPlugin { + constructor(component) { + this.parentModelBindings = []; + this.component = component; + const modelDirectives = getAllModelDirectiveFromElements(this.component.element); + this.parentModelBindings = modelDirectives.map(getModelBinding); + } + attachToComponent(component) { + component.on('request:started', (requestData) => { + requestData.children = this.getChildrenFingerprints(); + }); + component.on('model:set', (model, value) => { + this.notifyParentModelChange(model, value); + }); + } + getChildrenFingerprints() { + const fingerprints = {}; + this.getChildren().forEach((child) => { + if (!child.id) { + throw new Error('missing id'); + } + fingerprints[child.id] = { + fingerprint: child.fingerprint, + tag: child.element.tagName.toLowerCase(), + }; + }); + return fingerprints; + } + notifyParentModelChange(modelName, value) { + const parentComponent = findParent(this.component); + if (!parentComponent) { + return; + } + this.parentModelBindings.forEach((modelBinding) => { + const childModelName = modelBinding.innerModelName || 'value'; + if (childModelName !== modelName) { + return; + } + parentComponent.set(modelBinding.modelName, value, modelBinding.shouldRender, modelBinding.debounce); + }); + } + getChildren() { + return findChildren(this.component); + } +} + class LiveControllerDefault extends Controller { constructor() { super(...arguments); @@ -2917,46 +2917,22 @@ class LiveControllerDefault extends Controller { this.elementEventListeners = [ { event: 'input', callback: (event) => this.handleInputEvent(event) }, { event: 'change', callback: (event) => this.handleChangeEvent(event) }, - { event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, ]; this.pendingFiles = {}; } initialize() { - this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this); - const id = this.element.dataset.liveId || null; - this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, (currentComponent, onlyParents, onlyMatchName) => LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName), this.fingerprintValue, id, new Backend(this.urlValue, this.requestMethodValue, this.csrfValue), new StandardElementDriver()); - this.proxiedComponent = proxifyComponent(this.component); - this.element.__component = this.proxiedComponent; - if (this.hasDebounceValue) { - this.component.defaultDebounce = this.debounceValue; - } - const plugins = [ - new LoadingPlugin(), - new ValidatedFieldsPlugin(), - new PageUnloadingPlugin(), - new PollingPlugin(), - new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), - ]; - plugins.forEach((plugin) => { - this.component.addPlugin(plugin); - }); + this.mutationObserver = new MutationObserver(this.onMutations.bind(this)); + this.createComponent(); } connect() { - LiveControllerDefault.componentRegistry.registerComponent(this.element, this.component); - this.component.connect(); - this.elementEventListeners.forEach(({ event, callback }) => { - this.component.element.addEventListener(event, callback); + this.connectComponent(); + this.mutationObserver.observe(this.element, { + attributes: true, }); - this.dispatchEvent('connect'); } disconnect() { - LiveControllerDefault.componentRegistry.unregisterComponent(this.component); - this.component.disconnect(); - this.elementEventListeners.forEach(({ event, callback }) => { - this.component.element.removeEventListener(event, callback); - }); - this.dispatchEvent('disconnect'); + this.disconnectComponent(); + this.mutationObserver.disconnect(); } update(event) { if (event.type === 'input' || event.type === 'change') { @@ -3014,9 +2990,6 @@ class LiveControllerDefault extends Controller { } }); } - $render() { - return this.component.render(); - } emit(event) { this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => { this.component.emit(name, data, nameMatch); @@ -3032,6 +3005,18 @@ class LiveControllerDefault extends Controller { this.component.emitSelf(name, data); }); } + $render() { + return this.component.render(); + } + $updateModel(model, value, shouldRender = true, debounce = true) { + return this.component.set(model, value, shouldRender, debounce); + } + propsUpdatedFromParentValueChanged() { + this.component._updateFromParentProps(this.propsUpdatedFromParentValue); + } + fingerprintValueChanged() { + this.component.fingerprint = this.fingerprintValue; + } getEmitDirectives(event) { const element = event.currentTarget; if (!element.dataset.event) { @@ -3059,8 +3044,43 @@ class LiveControllerDefault extends Controller { }); return emits; } - $updateModel(model, value, shouldRender = true, debounce = true) { - return this.component.set(model, value, shouldRender, debounce); + createComponent() { + const id = this.element.id || null; + this.component = new Component(this.element, this.nameValue, this.propsValue, this.listenersValue, id, LiveControllerDefault.backendFactory(this), new StimulusElementDriver(this)); + this.proxiedComponent = proxifyComponent(this.component); + this.element.__component = this.proxiedComponent; + if (this.hasDebounceValue) { + this.component.defaultDebounce = this.debounceValue; + } + const plugins = [ + new LoadingPlugin(), + new ValidatedFieldsPlugin(), + new PageUnloadingPlugin(), + new PollingPlugin(), + new SetValueOntoModelFieldsPlugin(), + new QueryStringPlugin(this.queryMappingValue), + new ChildComponentPlugin(this.component), + ]; + plugins.forEach((plugin) => { + this.component.addPlugin(plugin); + }); + } + connectComponent() { + this.component.connect(); + this.mutationObserver.observe(this.element, { + attributes: true, + }); + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.addEventListener(event, callback); + }); + this.dispatchEvent('connect'); + } + disconnectComponent() { + this.component.disconnect(); + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.removeEventListener(event, callback); + }); + this.dispatchEvent('disconnect'); } handleInputEvent(event) { const target = event.target; @@ -3121,45 +3141,37 @@ class LiveControllerDefault extends Controller { const finalValue = getValueFromElement(element, this.component.valueStore); this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce); } - handleConnectedControllerEvent(event) { - if (event.target === this.element) { - return; - } - const childController = event.detail.controller; - if (childController.component.getParent()) { - return; - } - const modelDirectives = getAllModelDirectiveFromElements(childController.element); - const modelBindings = modelDirectives.map(getModelBinding); - this.component.addChild(childController.component, modelBindings); - childController.element.addEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); - } - handleDisconnectedChildControllerEvent(event) { - const childController = event.detail.controller; - childController.element.removeEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); - if (childController.component.getParent() !== this.component) { - return; - } - this.component.removeChild(childController.component); - } dispatchEvent(name, detail = {}, canBubble = true, cancelable = false) { detail.controller = this; detail.component = this.proxiedComponent; this.dispatch(name, { detail, prefix: 'live', cancelable, bubbles: canBubble }); } + onMutations(mutations) { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && + mutation.attributeName === 'id' && + this.element.id !== this.component.id) { + this.disconnectComponent(); + this.createComponent(); + this.connectComponent(); + } + }); + } } LiveControllerDefault.values = { name: String, url: String, - props: Object, + props: { type: Object, default: {} }, + propsUpdatedFromParent: { type: Object, default: {} }, csrf: String, listeners: { type: Array, default: [] }, + eventsToEmit: { type: Array, default: [] }, + eventsToDispatch: { type: Array, default: [] }, debounce: { type: Number, default: 150 }, - id: String, fingerprint: { type: String, default: '' }, requestMethod: { type: String, default: 'post' }, queryMapping: { type: Object, default: {} }, }; -LiveControllerDefault.componentRegistry = new ComponentRegistry(); +LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue, controller.csrfValue); export { Component, LiveControllerDefault as default, getComponent }; diff --git a/src/LiveComponent/assets/dist/morphdom.d.ts b/src/LiveComponent/assets/dist/morphdom.d.ts index 20d83e7b3f5..1b157b0fb38 100644 --- a/src/LiveComponent/assets/dist/morphdom.d.ts +++ b/src/LiveComponent/assets/dist/morphdom.d.ts @@ -1,3 +1,2 @@ -import Component from './Component'; import ExternalMutationTracker from './Rendering/ExternalMutationTracker'; -export declare function executeMorphdom(rootFromElement: HTMLElement, rootToElement: HTMLElement, modifiedFieldElements: Array, getElementValue: (element: HTMLElement) => any, childComponents: Component[], findChildComponent: (id: string, element: HTMLElement) => HTMLElement | null, getKeyFromElement: (element: HTMLElement) => string | null, externalMutationTracker: ExternalMutationTracker): void; +export declare function executeMorphdom(rootFromElement: HTMLElement, rootToElement: HTMLElement, modifiedFieldElements: Array, getElementValue: (element: HTMLElement) => any, externalMutationTracker: ExternalMutationTracker): void; diff --git a/src/LiveComponent/assets/src/Component/ElementDriver.ts b/src/LiveComponent/assets/src/Component/ElementDriver.ts index 0be5bdf7cf4..c3a8204e302 100644 --- a/src/LiveComponent/assets/src/Component/ElementDriver.ts +++ b/src/LiveComponent/assets/src/Component/ElementDriver.ts @@ -1,32 +1,29 @@ import {getModelDirectiveFromElement} from '../dom_utils'; +import LiveControllerDefault from '../live_controller'; export interface ElementDriver { getModelName(element: HTMLElement): string|null; - getComponentProps(rootElement: HTMLElement): any; - - /** - * Given an HtmlElement and a child id, find the root element for that child. - */ - findChildComponentElement(id: string, element: HTMLElement): HTMLElement|null; - - /** - * Given an element, find the "key" that should be used to identify it; - */ - getKeyFromElement(element: HTMLElement): string|null; + getComponentProps(): any; /** * Given an element from a response, find all the events that should be emitted. */ - getEventsToEmit(element: HTMLElement): Array<{event: string, data: any, target: string|null, componentName: string|null }>; + getEventsToEmit(): Array<{event: string, data: any, target: string|null, componentName: string|null }>; /** * Given an element from a response, find all the events that should be dispatched. */ - getBrowserEventsToDispatch(element: HTMLElement): Array<{event: string, payload: any }>; + getBrowserEventsToDispatch(): Array<{event: string, payload: any }>; } -export class StandardElementDriver implements ElementDriver { +export class StimulusElementDriver implements ElementDriver { + private readonly controller: LiveControllerDefault; + + constructor(controller: LiveControllerDefault) { + this.controller = controller; + } + getModelName(element: HTMLElement): string|null { const modelDirective = getModelDirectiveFromElement(element, false); @@ -37,29 +34,15 @@ export class StandardElementDriver implements ElementDriver { return modelDirective.action; } - getComponentProps(rootElement: HTMLElement): any { - const propsJson = rootElement.dataset.livePropsValue ?? '{}'; - - return JSON.parse(propsJson); - } - - findChildComponentElement(id: string, element: HTMLElement): HTMLElement|null { - return element.querySelector(`[data-live-id=${id}]`); + getComponentProps(): any { + return this.controller.propsValue; } - getKeyFromElement(element: HTMLElement): string|null { - return element.dataset.liveId || null; + getEventsToEmit(): Array<{event: string, data: any, target: string|null, componentName: string|null }> { + return this.controller.eventsToEmitValue; } - getEventsToEmit(element: HTMLElement): Array<{event: string, data: any, target: string|null, componentName: string|null }> { - const eventsJson = element.dataset.liveEmit ?? '[]'; - - return JSON.parse(eventsJson); - } - - getBrowserEventsToDispatch(element: HTMLElement): Array<{event: string, payload: any }> { - const eventsJson = element.dataset.liveBrowserDispatch ?? '[]'; - - return JSON.parse(eventsJson); + getBrowserEventsToDispatch(): Array<{event: string, payload: any }> { + return this.controller.eventsToDispatchValue; } } diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 49026af9f6c..a59ccee9017 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -1,4 +1,4 @@ -import { BackendAction, BackendInterface, ChildrenFingerprints } from '../Backend/Backend'; +import { BackendAction, BackendInterface } from '../Backend/Backend'; import ValueStore from './ValueStore'; import { normalizeModelName } from '../string_utils'; import BackendRequest from '../Backend/BackendRequest'; @@ -9,31 +9,18 @@ import { ElementDriver } from './ElementDriver'; import HookManager from '../HookManager'; import { PluginInterface } from './plugins/PluginInterface'; import BackendResponse from '../Backend/BackendResponse'; -import { ModelBinding } from '../Directive/get_model_binding'; import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; +import { findComponents, registerComponent, unregisterComponent } from '../ComponentRegistry'; declare const Turbo: any; -export type ComponentFinder = (currentComponent: Component, onlyParents: boolean, onlyMatchName: string|null) => Component[]; - -class ChildComponentWrapper { - component: Component; - modelBindings: ModelBinding[]; - - constructor(component: Component, modelBindings: ModelBinding[]) { - this.component = component; - this.modelBindings = modelBindings; - } -} - export default class Component { readonly element: HTMLElement; readonly name: string; // key is the string event name and value is an array of action names readonly listeners: Map; - private readonly componentFinder: ComponentFinder; private backend: BackendInterface; - private readonly elementDriver: ElementDriver; + readonly elementDriver: ElementDriver; id: string|null; /** @@ -43,13 +30,12 @@ export default class Component { * to determine if any "input" to the child component changed and thus, * if the child component needs to be re-rendered. */ - fingerprint: string|null; + fingerprint = ''; readonly valueStore: ValueStore; private readonly unsyncedInputsTracker: UnsyncedInputsTracker; private hooks: HookManager; - defaultDebounce = 150; private backendRequest: BackendRequest|null = null; @@ -64,9 +50,6 @@ export default class Component { private nextRequestPromise: Promise; private nextRequestPromiseResolve: (response: BackendResponse) => any; - private children: Map = new Map(); - private parent: Component|null = null; - private externalMutationTracker: ExternalMutationTracker; /** @@ -74,20 +57,16 @@ export default class Component { * @param name The name of the component * @param props Readonly component props * @param listeners Array of event -> action listeners - * @param componentFinder - * @param fingerprint * @param id Some unique id to identify this component. Needed to be a child component * @param backend Backend instance for updating * @param elementDriver Class to get "model" name from any element. */ - constructor(element: HTMLElement, name: string, props: any, listeners: Array<{ event: string; action: string }>, componentFinder: ComponentFinder, fingerprint: string|null, id: string|null, backend: BackendInterface, elementDriver: ElementDriver) { + constructor(element: HTMLElement, name: string, props: any, listeners: Array<{ event: string; action: string }>, id: string|null, backend: BackendInterface, elementDriver: ElementDriver) { this.element = element; this.name = name; - this.componentFinder = componentFinder; this.backend = backend; this.elementDriver = elementDriver; this.id = id; - this.fingerprint = fingerprint; this.listeners = new Map(); listeners.forEach((listener) => { @@ -109,15 +88,6 @@ export default class Component { // start early to catch any mutations that happen before the component is connected // for example, the LoadingPlugin, which sets initial non-loading state this.externalMutationTracker.start(); - - this.onChildComponentModelUpdate = this.onChildComponentModelUpdate.bind(this); - } - - /** - * @internal - */ - _swapBackend(backend: BackendInterface) { - this.backend = backend; } addPlugin(plugin: PluginInterface) { @@ -125,12 +95,14 @@ export default class Component { } connect(): void { + registerComponent(this); this.hooks.triggerHook('connect', this); this.unsyncedInputsTracker.activate(); this.externalMutationTracker.start(); } disconnect(): void { + unregisterComponent(this); this.hooks.triggerHook('disconnect', this); this.clearRequestDebounceTimeout(); this.unsyncedInputsTracker.deactivate(); @@ -142,6 +114,7 @@ export default class Component { * * * connect (component: Component) => {} * * disconnect (component: Component) => {} + * * request:started (requestConfig: any) => {} * * render:started (html: string, response: BackendResponse, controls: { shouldRender: boolean }) => {} * * render:finished (component: Component) => {} * * response:error (backendResponse: BackendResponse, controls: { displayError: boolean }) => {} @@ -219,39 +192,6 @@ export default class Component { return this.unsyncedInputsTracker.getUnsyncedModels(); } - addChild(child: Component, modelBindings: ModelBinding[] = []): void { - if (!child.id) { - throw new Error('Children components must have an id.'); - } - - this.children.set(child.id, new ChildComponentWrapper(child, modelBindings)); - child.parent = this; - child.on('model:set', this.onChildComponentModelUpdate); - } - - removeChild(child: Component): void { - if (!child.id) { - throw new Error('Children components must have an id.'); - } - - this.children.delete(child.id); - child.parent = null; - child.off('model:set', this.onChildComponentModelUpdate); - } - - getParent(): Component|null { - return this.parent; - } - - getChildren(): Map { - const children: Map = new Map(); - this.children.forEach((childComponent, id) => { - children.set(id, childComponent.component); - }); - - return children; - } - emit(name: string, data: any, onlyMatchingComponentsNamed: string|null = null): void { return this.performEmit(name, data, false, onlyMatchingComponentsNamed); } @@ -265,7 +205,7 @@ export default class Component { } private performEmit(name: string, data: any, emitUp: boolean, matchingName: string|null): void { - const components = this.componentFinder(this, emitUp, matchingName); + const components: Component[] = findComponents(this, emitUp, matchingName); components.forEach((component) => { component.doEmit(name, data); }); @@ -284,65 +224,6 @@ export default class Component { }); } - /** - * Called during morphdom: read props from toEl and re-render if necessary. - * - * @param toEl - */ - updateFromNewElementFromParentRender(toEl: HTMLElement): boolean { - const props = this.elementDriver.getComponentProps(toEl); - - // if no props are on the element, use the existing element completely - // this means the parent is signaling that the child does not need to be re-rendered - if (props === null) { - return false; - } - - // push props directly down onto the value store - const isChanged = this.valueStore.storeNewPropsFromParent(props); - - const fingerprint = toEl.dataset.liveFingerprintValue; - if (fingerprint !== undefined) { - this.fingerprint = fingerprint; - } - - if (isChanged) { - this.render(); - } - - return isChanged; - } - - /** - * Handles data-model binding from a parent component onto a child. - */ - onChildComponentModelUpdate(modelName: string, value: any, childComponent: Component): void { - if (!childComponent.id) { - throw new Error('Missing id'); - } - - const childWrapper = this.children.get(childComponent.id); - if (!childWrapper) { - throw new Error('Missing child'); - } - - childWrapper.modelBindings.forEach((modelBinding) => { - const childModelName = modelBinding.innerModelName || 'value'; - - // skip, unless childModelName matches the model that just changed - if (childModelName !== modelName) { - return; - } - - this.set( - modelBinding.modelName, - value, - modelBinding.shouldRender, - modelBinding.debounce - ); - }); - } - private isTurboEnabled(): boolean { return typeof Turbo !== 'undefined' && !this.element.closest('[data-turbo="false"]'); } @@ -375,13 +256,22 @@ export default class Component { } } + const requestConfig = { + props: this.valueStore.getOriginalProps(), + actions: this.pendingActions, + updated: this.valueStore.getDirtyProps(), + children: {}, + updatedPropsFromParent: this.valueStore.getUpdatedPropsFromParent(), + files: filesToSend, + }; + this.hooks.triggerHook('request:started', requestConfig); this.backendRequest = this.backend.makeRequest( - this.valueStore.getOriginalProps(), - this.pendingActions, - this.valueStore.getDirtyProps(), - this.getChildrenFingerprints(), - this.valueStore.getUpdatedPropsFromParent(), - filesToSend, + requestConfig.props, + requestConfig.actions, + requestConfig.updated, + requestConfig.children, + requestConfig.updatedPropsFromParent, + requestConfig.files ); this.hooks.triggerHook('loading.state:started', this.element, this.backendRequest); @@ -478,12 +368,6 @@ export default class Component { throw error; } - const newProps = this.elementDriver.getComponentProps(newElement); - this.valueStore.reinitializeAllProps(newProps); - - const eventsToEmit = this.elementDriver.getEventsToEmit(newElement); - const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(newElement); - // make sure we've processed all external changes before morphing this.externalMutationTracker.handlePendingChanges(); this.externalMutationTracker.stop(); @@ -492,13 +376,16 @@ export default class Component { newElement, this.unsyncedInputsTracker.getUnsyncedInputs(), (element: HTMLElement) => getValueFromElement(element, this.valueStore), - Array.from(this.getChildren().values()), - this.elementDriver.findChildComponentElement, - this.elementDriver.getKeyFromElement, this.externalMutationTracker ); this.externalMutationTracker.start(); + const newProps = this.elementDriver.getComponentProps(); + this.valueStore.reinitializeAllProps(newProps); + + const eventsToEmit = this.elementDriver.getEventsToEmit(); + const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(); + // reset the modified values back to their client-side version Object.keys(modifiedModelValues).forEach((modelName) => { this.valueStore.set(modelName, modifiedModelValues[modelName]); @@ -609,30 +496,24 @@ export default class Component { modal.focus(); } - private getChildrenFingerprints(): ChildrenFingerprints { - const fingerprints: ChildrenFingerprints = {}; - - this.children.forEach((childComponent) => { - const child = childComponent.component; - if (!child.id) { - throw new Error('missing id'); - } - - fingerprints[child.id] = { - fingerprint: child.fingerprint as string, - tag: child.element.tagName.toLowerCase(), - }; - }); - - return fingerprints; - } - private resetPromise(): void { this.nextRequestPromise = new Promise((resolve) => { this.nextRequestPromiseResolve = resolve; }); } + /** + * Called on a child component after the parent component render has requested + * that the child component update its props & re-render if necessary. + */ + _updateFromParentProps(props: any) { + // push props directly down onto the value store + const isChanged = this.valueStore.storeNewPropsFromParent(props); + + if (isChanged) { + this.render(); + } + } } /** diff --git a/src/LiveComponent/assets/src/Component/plugins/ChildComponentPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/ChildComponentPlugin.ts new file mode 100644 index 00000000000..1ad3e9c1cef --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/ChildComponentPlugin.ts @@ -0,0 +1,88 @@ +import Component from '../../Component'; +import { PluginInterface } from './PluginInterface'; +import { ChildrenFingerprints } from '../../Backend/Backend'; +import getModelBinding, { ModelBinding } from '../../Directive/get_model_binding'; +import { getAllModelDirectiveFromElements } from '../../dom_utils'; +import { findChildren, findParent } from '../../ComponentRegistry'; + +/** + * Handles all interactions for child components of a component. + * + * A) This parent component handling its children: + * * Sending children fingerprints to the server. + * + * B) This child component handling its parent: + * * Notifying the parent of a model change. + */ +export default class implements PluginInterface { + private readonly component: Component; + private parentModelBindings: ModelBinding[] = []; + + constructor(component: Component) { + this.component = component; + + const modelDirectives = getAllModelDirectiveFromElements(this.component.element); + this.parentModelBindings = modelDirectives.map(getModelBinding); + } + + attachToComponent(component: Component): void { + component.on('request:started', (requestData: any) => { + requestData.children = this.getChildrenFingerprints(); + }); + + component.on('model:set', (model: string, value: any) => { + this.notifyParentModelChange(model, value); + }); + } + + private getChildrenFingerprints(): ChildrenFingerprints { + const fingerprints: ChildrenFingerprints = {}; + + this.getChildren().forEach((child) => { + if (!child.id) { + throw new Error('missing id'); + } + + fingerprints[child.id] = { + fingerprint: child.fingerprint as string, + tag: child.element.tagName.toLowerCase(), + }; + }); + + return fingerprints; + } + + /** + * Notifies parent of a model change if desired. + * + * This makes the child "behave" like it's a normal `` element, + * where, when its value changes, the parent is notified. + */ + private notifyParentModelChange(modelName: string, value: any): void { + const parentComponent = findParent(this.component); + + if (!parentComponent) { + return; + } + + this.parentModelBindings.forEach((modelBinding) => { + const childModelName = modelBinding.innerModelName || 'value'; + + // skip, unless childModelName matches the model that just changed + if (childModelName !== modelName) { + return; + } + + parentComponent.set( + modelBinding.modelName, + value, + modelBinding.shouldRender, + modelBinding.debounce + ); + }); + } + + private getChildren(): Component[] { + return findChildren(this.component); + } +} diff --git a/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts index f2e307c7c81..ef5b7c71381 100644 --- a/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/LoadingPlugin.ts @@ -151,7 +151,7 @@ export default class implements PluginInterface { getLoadingDirectives(component: Component, element: HTMLElement|SVGElement) { const loadingDirectives: ElementLoadingDirectives[] = []; - let matchingElements = [...element.querySelectorAll('[data-loading]')]; + let matchingElements = [...Array.from(element.querySelectorAll('[data-loading]'))]; // ignore elements which are inside a nested "live" component matchingElements = matchingElements.filter((elt) => elementBelongsToThisComponent(elt, component)); diff --git a/src/LiveComponent/assets/src/ComponentRegistry.ts b/src/LiveComponent/assets/src/ComponentRegistry.ts index 752d6394043..3332d82359f 100644 --- a/src/LiveComponent/assets/src/ComponentRegistry.ts +++ b/src/LiveComponent/assets/src/ComponentRegistry.ts @@ -1,63 +1,119 @@ import Component from './Component'; -import { getElementAsTagText } from './dom_utils'; - -export default class { - private componentMapByElement = new WeakMap(); - /** - * The value is the component's name. - */ - private componentMapByComponent = new Map(); - - public registerComponent(element: HTMLElement, component: Component) { - this.componentMapByElement.set(element, component); - this.componentMapByComponent.set(component, component.name); - } +import getElementAsTagText from './Util/getElementAsTagText'; - public unregisterComponent(component: Component) { - this.componentMapByElement.delete(component.element); - this.componentMapByComponent.delete(component); - } +let componentMapByElement = new WeakMap(); +/** + * The value is the component's name. + */ +let componentMapByComponent = new Map(); - public getComponent(element: HTMLElement): Promise { - return new Promise((resolve, reject) => { - let count = 0; - const maxCount = 10; - const interval = setInterval(() => { - const component = this.componentMapByElement.get(element); - if (component) { - clearInterval(interval); - resolve(component); - } - count++; - - if (count > maxCount) { - clearInterval(interval); - reject(new Error(`Component not found for element ${getElementAsTagText(element)}`)); - } - }, 5); - }); - } +export const resetRegistry = function () { + componentMapByElement = new WeakMap(); + componentMapByComponent = new Map(); +}; + +export const registerComponent = function (component: Component) { + componentMapByElement.set(component.element, component); + componentMapByComponent.set(component, component.name); +}; + +export const unregisterComponent = function (component: Component) { + componentMapByElement.delete(component.element); + componentMapByComponent.delete(component); +}; + +export const getComponent = function (element: HTMLElement): Promise { + return new Promise((resolve, reject) => { + let count = 0; + const maxCount = 10; + const interval = setInterval(() => { + const component = componentMapByElement.get(element); + if (component) { + clearInterval(interval); + resolve(component); + } + count++; + + if (count > maxCount) { + clearInterval(interval); + reject(new Error(`Component not found for element ${getElementAsTagText(element)}`)); + } + }, 5); + }); +}; + +/** + * Returns a filtered list of all the currently-registered components + */ +export const findComponents = function ( + currentComponent: Component, + onlyParents: boolean, + onlyMatchName: string | null +): Component[] { + const components: Component[] = []; + componentMapByComponent.forEach((componentName: string, component: Component) => { + if (onlyParents && (currentComponent === component || !component.element.contains(currentComponent.element))) { + return; + } - /** - * Returns a filtered list of all the currently-registered components - */ - findComponents(currentComponent: Component, onlyParents: boolean, onlyMatchName: string | null): Component[] { - const components: Component[] = []; - this.componentMapByComponent.forEach((componentName: string, component: Component) => { - if ( - onlyParents && - (currentComponent === component || !component.element.contains(currentComponent.element)) - ) { + if (onlyMatchName && componentName !== onlyMatchName) { + return; + } + + components.push(component); + }); + + return components; +}; + +/** + * Returns an array of components that are direct children of the given component. + */ +export const findChildren = function (currentComponent: Component): Component[] { + const children: Component[] = []; + componentMapByComponent.forEach((componentName: string, component: Component) => { + if (currentComponent === component) { + return; + } + + if (!currentComponent.element.contains(component.element)) { + return; + } + + // check if there are any other components between the two + let foundChildComponent = false; + componentMapByComponent.forEach((childComponentName: string, childComponent: Component) => { + if (foundChildComponent) { + // return early return; } - if (onlyMatchName && componentName !== onlyMatchName) { + if (childComponent === component) { return; } - components.push(component); + if (childComponent.element.contains(component.element)) { + foundChildComponent = true; + } }); - return components; + children.push(component); + }); + + return children; +}; + +export const findParent = function (currentComponent: Component): Component | null { + // recursively traverse the node tree up to find a parent + let parentElement = currentComponent.element.parentElement; + while (parentElement) { + const component = componentMapByElement.get(parentElement); + if (component) { + return component; + } + + parentElement = parentElement.parentElement; } -} + + return null; +}; diff --git a/src/LiveComponent/assets/src/Util/getElementAsTagText.ts b/src/LiveComponent/assets/src/Util/getElementAsTagText.ts new file mode 100644 index 00000000000..42e10daa958 --- /dev/null +++ b/src/LiveComponent/assets/src/Util/getElementAsTagText.ts @@ -0,0 +1,14 @@ +/** + * Returns just the outer element's HTML as a string - useful for error messages. + * + * For example: + *
And text inside

more text

+ * + * Would return: + *
+ */ +export default function getElementAsTagText(element: HTMLElement): string { + return element.innerHTML + ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) + : element.outerHTML; +} diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index 310692e3a3c..b756f3de874 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -2,6 +2,8 @@ import ValueStore from './Component/ValueStore'; import { Directive, parseDirectives } from './Directive/directives_parser'; import { normalizeModelName } from './string_utils'; import Component from './Component'; +import { findChildren } from './ComponentRegistry'; +import getElementAsTagText from './Util/getElementAsTagText'; /** * Return the "value" of any given element. @@ -206,7 +208,7 @@ export function elementBelongsToThisComponent(element: Element, component: Compo } let foundChildComponent = false; - component.getChildren().forEach((childComponent) => { + findChildren(component).forEach((childComponent) => { if (foundChildComponent) { // return early return; @@ -254,21 +256,6 @@ export function htmlToElement(html: string): HTMLElement { return child; } -/** - * Returns just the outer element's HTML as a string - useful for error messages. - * - * For example: - *
And text inside

more text

- * - * Would return: - *
- */ -export function getElementAsTagText(element: HTMLElement): string { - return element.innerHTML - ? element.outerHTML.slice(0, element.outerHTML.indexOf(element.innerHTML)) - : element.outerHTML; -} - const getMultipleCheckboxValue = function (element: HTMLInputElement, currentValues: Array): Array { const finalValues = [...currentValues]; const value = inputValue(element); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 86c4bf96632..25025c5522b 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -1,15 +1,9 @@ import { Controller } from '@hotwired/stimulus'; import { parseDirectives, DirectiveModifier } from './Directive/directives_parser'; -import { - getModelDirectiveFromElement, - getElementAsTagText, - getValueFromElement, - elementBelongsToThisComponent, - getAllModelDirectiveFromElements, -} from './dom_utils'; +import { getModelDirectiveFromElement, getValueFromElement, elementBelongsToThisComponent } from './dom_utils'; import Component, { proxifyComponent } from './Component'; -import Backend from './Backend/Backend'; -import { StandardElementDriver } from './Component/ElementDriver'; +import Backend, { BackendInterface } from './Backend/Backend'; +import { StimulusElementDriver } from './Component/ElementDriver'; import LoadingPlugin from './Component/plugins/LoadingPlugin'; import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; import PageUnloadingPlugin from './Component/plugins/PageUnloadingPlugin'; @@ -17,12 +11,12 @@ import PollingPlugin from './Component/plugins/PollingPlugin'; import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin'; import { PluginInterface } from './Component/plugins/PluginInterface'; import getModelBinding from './Directive/get_model_binding'; -import ComponentRegistry from './ComponentRegistry'; import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; +import ChildComponentPlugin from './Component/plugins/ChildComponentPlugin'; +import getElementAsTagText from './Util/getElementAsTagText'; export { Component }; -export const getComponent = (element: HTMLElement): Promise => - LiveControllerDefault.componentRegistry.getComponent(element); +export { getComponent } from './ComponentRegistry'; export interface LiveEvent extends CustomEvent { detail: { @@ -39,11 +33,13 @@ export default class LiveControllerDefault extends Controller imple static values = { name: String, url: String, - props: Object, + props: { type: Object, default: {} }, + propsUpdatedFromParent: { type: Object, default: {} }, csrf: String, listeners: { type: Array, default: [] }, + eventsToEmit: { type: Array, default: [] }, + eventsToDispatch: { type: Array, default: [] }, debounce: { type: Number, default: 150 }, - id: String, fingerprint: { type: String, default: '' }, requestMethod: { type: String, default: 'post' }, queryMapping: { type: Object, default: {} }, @@ -52,8 +48,16 @@ export default class LiveControllerDefault extends Controller imple declare readonly nameValue: string; declare readonly urlValue: string; declare readonly propsValue: any; + declare propsUpdatedFromParentValue: any; declare readonly csrfValue: string; declare readonly listenersValue: Array<{ event: string; action: string }>; + declare readonly eventsToEmitValue: Array<{ + event: string; + data: any; + target: string | null; + componentName: string | null; + }>; + declare readonly eventsToDispatchValue: Array<{ event: string; payload: any }>; declare readonly hasDebounceValue: boolean; declare readonly debounceValue: number; declare readonly fingerprintValue: string; @@ -62,6 +66,7 @@ export default class LiveControllerDefault extends Controller imple /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; + private mutationObserver: MutationObserver; /** The raw Component object */ component: Component; pendingActionTriggerModelElement: HTMLElement | null = null; @@ -69,71 +74,30 @@ export default class LiveControllerDefault extends Controller imple private elementEventListeners: Array<{ event: string; callback: (event: any) => void }> = [ { event: 'input', callback: (event) => this.handleInputEvent(event) }, { event: 'change', callback: (event) => this.handleChangeEvent(event) }, - { event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) }, ]; private pendingFiles: { [key: string]: HTMLInputElement } = {}; - static componentRegistry = new ComponentRegistry(); + static backendFactory: (controller: LiveControllerDefault) => BackendInterface = (controller) => + new Backend(controller.urlValue, controller.requestMethodValue, controller.csrfValue); initialize() { - this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this); + this.mutationObserver = new MutationObserver(this.onMutations.bind(this)); - const id = this.element.dataset.liveId || null; - - this.component = new Component( - this.element, - this.nameValue, - this.propsValue, - this.listenersValue, - (currentComponent: Component, onlyParents: boolean, onlyMatchName: string | null) => - LiveControllerDefault.componentRegistry.findComponents(currentComponent, onlyParents, onlyMatchName), - this.fingerprintValue, - id, - new Backend(this.urlValue, this.requestMethodValue, this.csrfValue), - new StandardElementDriver() - ); - this.proxiedComponent = proxifyComponent(this.component); - - // @ts-ignore Adding the dynamic property - this.element.__component = this.proxiedComponent; - - if (this.hasDebounceValue) { - this.component.defaultDebounce = this.debounceValue; - } - - const plugins: PluginInterface[] = [ - new LoadingPlugin(), - new ValidatedFieldsPlugin(), - new PageUnloadingPlugin(), - new PollingPlugin(), - new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), - ]; - plugins.forEach((plugin) => { - this.component.addPlugin(plugin); - }); + this.createComponent(); } connect() { - LiveControllerDefault.componentRegistry.registerComponent(this.element, this.component); - this.component.connect(); + this.connectComponent(); - this.elementEventListeners.forEach(({ event, callback }) => { - this.component.element.addEventListener(event, callback); + this.mutationObserver.observe(this.element, { + attributes: true, }); - - this.dispatchEvent('connect'); } - disconnect() { - LiveControllerDefault.componentRegistry.unregisterComponent(this.component); - this.component.disconnect(); + disconnect(): void { + this.disconnectComponent(); - this.elementEventListeners.forEach(({ event, callback }) => { - this.component.element.removeEventListener(event, callback); - }); - - this.dispatchEvent('disconnect'); + this.mutationObserver.disconnect(); } /** @@ -224,10 +188,6 @@ export default class LiveControllerDefault extends Controller imple }); } - $render() { - return this.component.render(); - } - emit(event: Event) { this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => { this.component.emit(name, data, nameMatch); @@ -246,6 +206,30 @@ export default class LiveControllerDefault extends Controller imple }); } + $render() { + return this.component.render(); + } + + /** + * Update a model value. + * + * @param {string} model The model to update + * @param {any} value The new value + * @param {boolean} shouldRender Whether a re-render should be triggered + * @param {number|boolean} debounce + */ + $updateModel(model: string, value: any, shouldRender = true, debounce: number | boolean = true) { + return this.component.set(model, value, shouldRender, debounce); + } + + propsUpdatedFromParentValueChanged() { + this.component._updateFromParentProps(this.propsUpdatedFromParentValue); + } + + fingerprintValueChanged() { + this.component.fingerprint = this.fingerprintValue; + } + private getEmitDirectives(event: Event): Array<{ name: string; data: any; nameMatch: string | null }> { const element = event.currentTarget as HTMLElement; if (!element.dataset.event) { @@ -279,24 +263,60 @@ export default class LiveControllerDefault extends Controller imple return emits; } - /** - * Update a model value. - * - * The extraModelName should be set to the "name" attribute of an element - * if it has one. This is only important in a parent/child component, - * where, in the child, you might be updating a "foo" model, but you - * also want this update to "sync" to the parent component's "bar" model. - * Typically, setup on a field like this: - * - * - * - * @param {string} model The model to update - * @param {any} value The new value - * @param {boolean} shouldRender Whether a re-render should be triggered - * @param {number|boolean} debounce - */ - $updateModel(model: string, value: any, shouldRender = true, debounce: number | boolean = true) { - return this.component.set(model, value, shouldRender, debounce); + private createComponent(): void { + const id = this.element.id || null; + + this.component = new Component( + this.element, + this.nameValue, + this.propsValue, + this.listenersValue, + id, + LiveControllerDefault.backendFactory(this), + new StimulusElementDriver(this) + ); + this.proxiedComponent = proxifyComponent(this.component); + + // @ts-ignore Adding the dynamic property + this.element.__component = this.proxiedComponent; + + if (this.hasDebounceValue) { + this.component.defaultDebounce = this.debounceValue; + } + + const plugins: PluginInterface[] = [ + new LoadingPlugin(), + new ValidatedFieldsPlugin(), + new PageUnloadingPlugin(), + new PollingPlugin(), + new SetValueOntoModelFieldsPlugin(), + new QueryStringPlugin(this.queryMappingValue), + new ChildComponentPlugin(this.component), + ]; + plugins.forEach((plugin) => { + this.component.addPlugin(plugin); + }); + } + + private connectComponent() { + this.component.connect(); + this.mutationObserver.observe(this.element, { + attributes: true, + }); + + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.addEventListener(event, callback); + }); + + this.dispatchEvent('connect'); + } + + private disconnectComponent() { + this.component.disconnect(); + this.elementEventListeners.forEach(({ event, callback }) => { + this.component.element.removeEventListener(event, callback); + }); + this.dispatchEvent('disconnect'); } private handleInputEvent(event: Event) { @@ -401,48 +421,24 @@ export default class LiveControllerDefault extends Controller imple this.component.set(modelBinding.modelName, finalValue, modelBinding.shouldRender, modelBinding.debounce); } - handleConnectedControllerEvent(event: LiveEvent): void { - if (event.target === this.element) { - return; - } - - const childController = event.detail.controller; - if (childController.component.getParent()) { - // child already has a parent - we are a grandparent - return; - } - - const modelDirectives = getAllModelDirectiveFromElements(childController.element); - const modelBindings = modelDirectives.map(getModelBinding); - - this.component.addChild(childController.component, modelBindings); - - // live:disconnect needs to be registered on the child element directly - // that's because if the child component is removed from the DOM, then - // the parent controller is no longer an ancestor, so the live:disconnect - // event would not bubble up to it. - // @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix - childController.element.addEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); - } - - handleDisconnectedChildControllerEvent(event: LiveEvent): void { - const childController = event.detail.controller; - - // @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix - childController.element.removeEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent); - - // this shouldn't happen: but double-check we're the parent - if (childController.component.getParent() !== this.component) { - return; - } - - this.component.removeChild(childController.component); - } - private dispatchEvent(name: string, detail: any = {}, canBubble = true, cancelable = false) { detail.controller = this; detail.component = this.proxiedComponent; this.dispatch(name, { detail, prefix: 'live', cancelable, bubbles: canBubble }); } + + private onMutations(mutations: MutationRecord[]): void { + mutations.forEach((mutation) => { + if ( + mutation.type === 'attributes' && + mutation.attributeName === 'id' && + this.element.id !== this.component.id + ) { + this.disconnectComponent(); + this.createComponent(); + this.connectComponent(); + } + }); + } } diff --git a/src/LiveComponent/assets/src/morphdom.ts b/src/LiveComponent/assets/src/morphdom.ts index 52a45536d53..7dba0668d9a 100644 --- a/src/LiveComponent/assets/src/morphdom.ts +++ b/src/LiveComponent/assets/src/morphdom.ts @@ -2,22 +2,54 @@ import { cloneHTMLElement, setValueOnElement } from './dom_utils'; // @ts-ignore import { Idiomorph } from 'idiomorph/dist/idiomorph.esm.js'; import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; -import Component from './Component'; import ExternalMutationTracker from './Rendering/ExternalMutationTracker'; +const syncAttributes = function (fromEl: Element, toEl: Element): void { + for (let i = 0; i < fromEl.attributes.length; i++) { + const attr = fromEl.attributes[i]; + toEl.setAttribute(attr.name, attr.value); + } +}; + export function executeMorphdom( rootFromElement: HTMLElement, rootToElement: HTMLElement, modifiedFieldElements: Array, getElementValue: (element: HTMLElement) => any, - childComponents: Component[], - findChildComponent: (id: string, element: HTMLElement) => HTMLElement | null, - getKeyFromElement: (element: HTMLElement) => string | null, externalMutationTracker: ExternalMutationTracker ) { - const childComponentMap: Map = new Map(); - childComponents.forEach((childComponent) => { - childComponentMap.set(childComponent.element, childComponent); + /* + * Handle "data-live-preserve" elements. + * + * These are elements that are empty and have requested that their + * content be preserved from the matching element of the existing HTML. + * + * To handle them, we: + * 1) Create an array of the "current" HTMLElements that match each + * "data-live-preserve" element. + * 2) Replace the "current" elements with clones so that the originals + * aren't modified during the morphing process. + * 3) After the morphing is complete, we find the preserved elements and + * replace them with the originals. + */ + const preservedOriginalElements: HTMLElement[] = []; + rootToElement.querySelectorAll('[data-live-preserve]').forEach((newElement) => { + const id = newElement.id; + if (!id) { + throw new Error('The data-live-preserve attribute requires an id attribute to be set on the element'); + } + + const oldElement = rootFromElement.querySelector(`#${id}`); + if (!(oldElement instanceof HTMLElement)) { + throw new Error(`The element with id "${id}" was not found in the original HTML`); + } + + const clonedOldElement = cloneHTMLElement(oldElement); + preservedOriginalElements.push(oldElement); + oldElement.replaceWith(clonedOldElement); + + newElement.removeAttribute('data-live-preserve'); + syncAttributes(newElement, oldElement); }); Idiomorph.morph(rootFromElement, rootToElement, { @@ -32,44 +64,31 @@ export function executeMorphdom( return true; } - let idChanged = false; - // Track children if data-live-id changed - if (fromEl.hasAttribute('data-live-id')) { - if (fromEl.getAttribute('data-live-id') !== toEl.getAttribute('data-live-id')) { - for (const child of fromEl.children) { - child.setAttribute('parent-live-id-changed', ''); - } - idChanged = true; - } - } - // skip special checking if this is, for example, an SVG if (fromEl instanceof HTMLElement && toEl instanceof HTMLElement) { // We assume fromEl is an Alpine component if it has `__x` property. // If it's the case, then we should morph `fromEl` to `ToEl` (thanks to https://alpinejs.dev/plugins/morph) // in order to keep the component state and UI in sync. + // @ts-ignore if (typeof fromEl.__x !== 'undefined') { + // @ts-ignore if (!window.Alpine) { throw new Error( 'Unable to access Alpine.js though the global window.Alpine variable. Please make sure Alpine.js is loaded before Symfony UX LiveComponent.' ); } + // @ts-ignore if (typeof window.Alpine.morph !== 'function') { throw new Error( 'Unable to access Alpine.js morph function. Please make sure the Alpine.js Morph plugin is installed and loaded, see https://alpinejs.dev/plugins/morph for more information.' ); } + // @ts-ignore window.Alpine.morph(fromEl.__x, toEl); } - if (childComponentMap.has(fromEl)) { - const childComponent = childComponentMap.get(fromEl) as Component; - - return !childComponent.updateFromNewElementFromParentRender(toEl) && idChanged; - } - if (externalMutationTracker.wasElementAdded(fromEl)) { fromEl.insertAdjacentElement('afterend', toEl); return false; @@ -106,19 +125,17 @@ export function executeMorphdom( } } - // Update child if parent has his live-id changed - if (fromEl.hasAttribute('parent-live-id-changed')) { - fromEl.removeAttribute('parent-live-id-changed'); - - return true; - } - - if (fromEl.hasAttribute('data-skip-morph')) { + // data-skip-morph implies you want this element's innerHTML to be + // replaced, not morphed. We add the same behavior to elements where + // the id has changed. So, even if a
appears on the + // same place as a
, we replace the content to get + // totally fresh internals. + if (fromEl.hasAttribute('data-skip-morph') || (fromEl.id && fromEl.id !== toEl.id)) { fromEl.innerHTML = toEl.innerHTML; return true; } - + // if parent's innerHTML was replaced, skip morphing on child if (fromEl.parentElement && fromEl.parentElement.hasAttribute('data-skip-morph')) { return false; } @@ -143,13 +160,14 @@ export function executeMorphdom( }, }); - childComponentMap.forEach((childComponent, element) => { - const childComponentInResult = findChildComponent(childComponent.id ?? '', rootFromElement); - if (null === childComponentInResult || element === childComponentInResult) { - return; + preservedOriginalElements.forEach((oldElement) => { + const newElement = rootFromElement.querySelector(`#${oldElement.id}`); + if (!(newElement instanceof HTMLElement)) { + // should not happen, as preservedOriginalElements is built from + // the new HTML + throw new Error('Missing preserved element'); } - childComponentInResult?.replaceWith(element); - childComponent.updateFromNewElementFromParentRender(childComponentInResult); + newElement.replaceWith(oldElement); }); } diff --git a/src/LiveComponent/assets/test/Component/index.test.ts b/src/LiveComponent/assets/test/Component/index.test.ts index 27ffafb58ec..fb9eee10208 100644 --- a/src/LiveComponent/assets/test/Component/index.test.ts +++ b/src/LiveComponent/assets/test/Component/index.test.ts @@ -1,10 +1,10 @@ import Component, { proxifyComponent } from '../../src/Component'; import {BackendAction, BackendInterface} from '../../src/Backend/Backend'; -import { StandardElementDriver } from '../../src/Component/ElementDriver'; import BackendRequest from '../../src/Backend/BackendRequest'; import { Response } from 'node-fetch'; import { waitFor } from '@testing-library/dom'; import BackendResponse from '../../src/Backend/BackendResponse'; +import { noopElementDriver } from '../tools'; interface MockBackend extends BackendInterface { actions: BackendAction[], @@ -30,11 +30,9 @@ const makeTestComponent = (): { component: Component, backend: MockBackend } => 'test-component', { firstName: '', product: { name: '' } }, [], - () => [], - null, null, backend, - new StandardElementDriver() + new noopElementDriver(), ); return { diff --git a/src/LiveComponent/assets/test/ComponentRegistry.test.ts b/src/LiveComponent/assets/test/ComponentRegistry.test.ts index a8d3ffb2a2f..22429f0b568 100644 --- a/src/LiveComponent/assets/test/ComponentRegistry.test.ts +++ b/src/LiveComponent/assets/test/ComponentRegistry.test.ts @@ -1,9 +1,14 @@ import Component from '../src/Component'; -import ComponentRegistry from '../src/ComponentRegistry'; +import { + registerComponent, + resetRegistry, + getComponent, + findComponents, +} from '../src/ComponentRegistry'; import BackendRequest from '../src/Backend/BackendRequest'; import { BackendInterface } from '../src/Backend/Backend'; import { Response } from 'node-fetch'; -import { StandardElementDriver } from '../src/Component/ElementDriver'; +import { noopElementDriver } from './tools'; const createComponent = (element: HTMLElement, name = 'foo-component'): Component => { const backend: BackendInterface = { @@ -22,61 +27,55 @@ const createComponent = (element: HTMLElement, name = 'foo-component'): Componen name, {}, [], - () => [], - null, null, backend, - new StandardElementDriver(), + new noopElementDriver(), ); }; describe('ComponentRegistry', () => { - it('can add and retrieve components', async () => { - const registry = new ComponentRegistry(); + beforeEach(() => { + resetRegistry(); + }); + it('can add and retrieve components', async () => { const element1 = document.createElement('div'); const component1 = createComponent(element1); const element2 = document.createElement('div'); const component2 = createComponent(element2); - registry.registerComponent(element1, component1); - registry.registerComponent(element2, component2); + registerComponent(component1); + registerComponent(component2); - const promise1 = registry.getComponent(element1); - const promise2 = registry.getComponent(element2); + const promise1 = getComponent(element1); + const promise2 = getComponent(element2); await expect(promise1).resolves.toBe(component1); await expect(promise2).resolves.toBe(component2); }); it('fails if component is not found soon', async () => { - const registry = new ComponentRegistry(); - const element1 = document.createElement('div'); - const promise = registry.getComponent(element1); + const promise = getComponent(element1); expect.assertions(1); await expect(promise).rejects.toEqual(new Error('Component not found for element
')); }); it('can find components in the simple case', () => { - const registry = new ComponentRegistry(); - const element1 = document.createElement('div'); const component1 = createComponent(element1); const element2 = document.createElement('div'); const component2 = createComponent(element2); - registry.registerComponent(element1, component1); - registry.registerComponent(element2, component2); + registerComponent(component1); + registerComponent(component2); const otherComponent = createComponent(document.createElement('div')); - const components = registry.findComponents(otherComponent, false, null); + const components = findComponents(otherComponent, false, null); expect(components).toEqual([component1, component2]); }); it('can find components with only parents', () => { - const registry = new ComponentRegistry(); - const element1 = document.createElement('div'); const component1 = createComponent(element1); const element2 = document.createElement('div'); @@ -87,17 +86,15 @@ describe('ComponentRegistry', () => { // put component 2 inside component 1 element1.appendChild(element2); - registry.registerComponent(element1, component1); - registry.registerComponent(element2, component2); - registry.registerComponent(element3, component3); + registerComponent(component1); + registerComponent(component2); + registerComponent(component3); - const components = registry.findComponents(component2, true, null); + const components = findComponents(component2, true, null); expect(components).toEqual([component1]); }); it('can find components by name', () => { - const registry = new ComponentRegistry(); - const element1 = document.createElement('div'); const component1 = createComponent(element1, 'component-type1'); const element2 = document.createElement('div'); @@ -105,28 +102,26 @@ describe('ComponentRegistry', () => { const element3 = document.createElement('div'); const component3 = createComponent(element3, 'component-type2'); - registry.registerComponent(element1, component1); - registry.registerComponent(element2, component2); - registry.registerComponent(element3, component3); + registerComponent(component1); + registerComponent(component2); + registerComponent(component3); const otherComponent = createComponent(document.createElement('div')); - const components = registry.findComponents(otherComponent, false, 'component-type1'); + const components = findComponents(otherComponent, false, 'component-type1'); expect(components).toEqual([component1, component2]); }); it('will find components including itself', () => { - const registry = new ComponentRegistry(); - const element1 = document.createElement('div'); const component1 = createComponent(element1, 'component-type1'); const element2 = document.createElement('div'); const component2 = createComponent(element2, 'component-type1'); - registry.registerComponent(element1, component1); - registry.registerComponent(element2, component2); + registerComponent(component1); + registerComponent(component2); - const components = registry.findComponents(component2, false, null); + const components = findComponents(component2, false, null); expect(components).toEqual([component1, component2]); }); }); diff --git a/src/LiveComponent/assets/test/Util/getElementAsTagText.test.ts b/src/LiveComponent/assets/test/Util/getElementAsTagText.test.ts new file mode 100644 index 00000000000..8d1f457ff5e --- /dev/null +++ b/src/LiveComponent/assets/test/Util/getElementAsTagText.test.ts @@ -0,0 +1,16 @@ +import { htmlToElement } from '../../src/dom_utils'; +import getElementAsTagText from '../../src/Util/getElementAsTagText'; + +describe('getElementAsTagText', () => { + it('returns self-closing tag correctly', () => { + const element = htmlToElement(''); + + expect(getElementAsTagText(element)).toEqual('') + }); + + it('returns tag text without the innerHTML', () => { + const element = htmlToElement('
Name:
'); + + expect(getElementAsTagText(element)).toEqual('
') + }); +}); diff --git a/src/LiveComponent/assets/test/controller/basic.test.ts b/src/LiveComponent/assets/test/controller/basic.test.ts index be5f98da63a..a3e2c9cbba8 100644 --- a/src/LiveComponent/assets/test/controller/basic.test.ts +++ b/src/LiveComponent/assets/test/controller/basic.test.ts @@ -12,7 +12,8 @@ import {createTest, initComponent, shutdownTests, startStimulus} from '../tools'; import { htmlToElement } from '../../src/dom_utils'; import Component from '../../src/Component'; -import LiveControllerDefault, { getComponent } from '../../src/live_controller'; +import { getComponent } from '../../src/live_controller'; +import { findComponents } from '../../src/ComponentRegistry'; describe('LiveController Basic Tests', () => { afterEach(() => { @@ -41,13 +42,12 @@ describe('LiveController Basic Tests', () => { expect(test.component).toBeInstanceOf(Component); expect(test.component.defaultDebounce).toEqual(115); expect(test.component.id).toEqual('the-id'); - expect(test.component.fingerprint).toEqual('the-fingerprint'); await expect(getComponent(test.element)).resolves.toBe(test.component); - expect(LiveControllerDefault.componentRegistry.findComponents(test.component, false, null)[0]).toBe(test.component); + expect(findComponents(test.component, false, null)[0]).toBe(test.component); // check that it disconnects document.body.innerHTML = ''; await expect(getComponent(test.element)).rejects.toThrow('Component not found for element'); - expect(LiveControllerDefault.componentRegistry.findComponents(test.component, false, null)).toEqual([]); + expect(findComponents(test.component, false, null)).toEqual([]); }); }); diff --git a/src/LiveComponent/assets/test/controller/child-model.test.ts b/src/LiveComponent/assets/test/controller/child-model.test.ts index ad2daad0868..4dd1052da63 100644 --- a/src/LiveComponent/assets/test/controller/child-model.test.ts +++ b/src/LiveComponent/assets/test/controller/child-model.test.ts @@ -43,7 +43,7 @@ describe('Component parent -> child data-model binding tests', () => { .willReturn((data: any) => `
Food Name ${data.foodName} -
+
`); @@ -76,7 +76,7 @@ describe('Component parent -> child data-model binding tests', () => { .willReturn((data: any) => `
Food Name ${data.foodName} -
+
`); @@ -135,7 +135,7 @@ describe('Component parent -> child data-model binding tests', () => { .willReturn((data: any) => `
Food Name ${data.foodName} -
+
`); diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index d0da1fff483..aa9888a9da1 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -9,63 +9,23 @@ 'use strict'; -import { createTestForExistingComponent, createTest, initComponent, shutdownTests, getComponent } from '../tools'; +import { + createTestForExistingComponent, + createTest, + initComponent, + shutdownTests, + getComponent, + dataToJsonAttribute, +} from '../tools'; import { getByTestId, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; +import { findChildren } from '../../src/ComponentRegistry'; describe('Component parent -> child initialization and rendering tests', () => { afterEach(() => { shutdownTests(); }) - it('adds & removes the child correctly', async () => { - const childTemplate = (data: any) => ` -
- `; - - const test = await createTest({}, (data: any) => ` -
- ${childTemplate({})} -
- `); - - const parentComponent = test.component; - const childComponent = getComponent(getByTestId(test.element, 'child')); - // setting a marker to help verify THIS exact Component instance continues to be used - childComponent.fingerprint = 'FOO-FINGERPRINT'; - - // check that the relationships all loaded correctly - expect(parentComponent.getChildren().size).toEqual(1); - // check fingerprint instead of checking object equality with childComponent - // because childComponent is actually the proxied Component - expect(parentComponent.getChildren().get('the-child-id')?.fingerprint).toEqual('FOO-FINGERPRINT'); - expect(childComponent.getParent()).toBe(parentComponent); - - // remove the child - childComponent.element.remove(); - // wait because the event is slightly async - await waitFor(() => expect(parentComponent.getChildren().size).toEqual(0)); - expect(childComponent.getParent()).toBeNull(); - - // now put it back! - test.element.appendChild(childComponent.element); - await waitFor(() => expect(parentComponent.getChildren().size).toEqual(1)); - expect(parentComponent.getChildren().get('the-child-id')?.fingerprint).toEqual('FOO-FINGERPRINT'); - expect(childComponent.getParent()).toEqual(parentComponent); - - // now remove the whole darn thing! - test.element.remove(); - // this will, while disconnected, break the parent-child bond - await waitFor(() => expect(parentComponent.getChildren().size).toEqual(0)); - expect(childComponent.getParent()).toBeNull(); - - // put it *all* back - document.body.appendChild(test.element); - await waitFor(() => expect(parentComponent.getChildren().size).toEqual(1)); - expect(parentComponent.getChildren().get('the-child-id')?.fingerprint).toEqual('FOO-FINGERPRINT'); - expect(childComponent.getParent()).toEqual(parentComponent); - }); - it('sends a map of child fingerprints on re-render', async () => { const test = await createTest({}, (data: any) => `
@@ -100,13 +60,13 @@ describe('Component parent -> child initialization and rendering tests', () => { }); expect(test.element).toHaveTextContent('Child Component') - expect(test.component.getChildren().size).toEqual(1); + expect(findChildren(test.component).length).toEqual(1); test.component.render(); // wait for child to disappear await waitFor(() => expect(test.element).toHaveAttribute('busy')); await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); expect(test.element).not.toHaveTextContent('Child Component') - expect(test.component.getChildren().size).toEqual(0); + expect(findChildren(test.component).length).toEqual(0); }); it('adds new child component on re-render', async () => { @@ -125,23 +85,23 @@ describe('Component parent -> child initialization and rendering tests', () => { }); expect(test.element).not.toHaveTextContent('Child Component') - expect(test.component.getChildren().size).toEqual(0); + expect(findChildren(test.component).length).toEqual(0); test.component.render(); // wait for child to disappear await waitFor(() => expect(test.element).toHaveAttribute('busy')); await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); expect(test.element).toHaveTextContent('Child Component') - expect(test.component.getChildren().size).toEqual(1); + expect(findChildren(test.component).length).toEqual(1); }); - it('existing child component that has no props is ignored', async () => { + it('new child marked as data-live-preserve is ignored except for new attributes', async () => { const originalChild = `
Original Child Component
`; const updatedChild = ` -
+
Updated Child Component
`; @@ -164,6 +124,8 @@ describe('Component parent -> child initialization and rendering tests', () => { await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); // child component is STILL here: the new rendering was ignored expect(test.element).toHaveTextContent('Original Child Component') + expect(test.element).toContainHTML('data-new="bar"'); + expect(test.element).not.toContainHTML('data-live-preserve'); }); it('existing child component gets props & triggers re-render', async () => { @@ -179,10 +141,12 @@ describe('Component parent -> child initialization and rendering tests', () => { // a simpler version of the child is returned from the parent component's re-render const childReturnedFromParentCall = ` -
+
`; const test = await createTest({useOriginalChild: true}, (data: any) => ` @@ -257,14 +221,17 @@ describe('Component parent -> child initialization and rendering tests', () => { expect(childComponent.element).toHaveTextContent('Full Name: RYAN WEAVER') }); - it('replaces old child with new child if the "id" changes', async () => { - const originalChildTemplate = ` - + it('child controller changes its component if child id changes', async () => { + // both are a span in the same position: so the same Stimulus controller + // will be used for both. + const originalChildTemplate = (data: any) => ` + Original Child `; - const reRenderedChildTemplate = ` - + + const reRenderedChildTemplate = (data: any) => ` + New Child `; @@ -272,10 +239,12 @@ describe('Component parent -> child initialization and rendering tests', () => { const test = await createTest({useOriginalChild: true}, (data: any) => `
Parent Component - ${data.useOriginalChild ? originalChildTemplate : reRenderedChildTemplate} + ${data.useOriginalChild ? originalChildTemplate({name: 'original'}) : reRenderedChildTemplate({name: 'new'})}
`); + const originalChildElement = getByTestId(test.element, 'child-component'); + // Re-render the parent test.expectsAjaxCall() .serverWillChangeProps((data: any) => { @@ -291,7 +260,11 @@ describe('Component parent -> child initialization and rendering tests', () => { expect(test.element).toHaveTextContent('New Child') expect(test.element).not.toHaveTextContent('Original Child') - expect(test.component.getChildren().size).toEqual(1); + expect(findChildren(test.component).length).toEqual(1); + const newChildElement = getByTestId(test.element, 'child-component'); + expect(newChildElement).toEqual(originalChildElement); + const childComponent = getComponent(newChildElement); + expect(childComponent.id).toEqual('new-child-id'); }); it('tracks various children correctly, even if position changes', async () => { @@ -301,8 +274,13 @@ describe('Component parent -> child initialization and rendering tests', () => {
`; // the empty-ish child element used on re-render - const childRenderedFromParentTemplate = (data: any) => ` - + const emptyChildTemplate = (data: any) => ` + `; const test = await createTest({}, (data: any) => ` @@ -320,19 +298,16 @@ describe('Component parent -> child initialization and rendering tests', () => { .willReturn((data: any) => `
- ${childRenderedFromParentTemplate({ number: 2, value: 'New value for child 2' })} + ${emptyChildTemplate({ number: 2, value: 'New value for child 2' })}
Parent Component Updated
  • - ${childRenderedFromParentTemplate({ number: 1, value: 'New value for child 1' })} + ${emptyChildTemplate({ number: 1, value: 'New value for child 1' })}
`); - test.component.render(); - // wait for parent Ajax call to start - await waitFor(() => expect(test.element).toHaveAttribute('busy')); const childComponent1 = getComponent(getByTestId(test.element, 'child-component-1')); const childTest1 = createTestForExistingComponent(childComponent1); @@ -351,12 +326,15 @@ describe('Component parent -> child initialization and rendering tests', () => { .expectUpdatedPropsFromParent({ number: 2, value: 'New value for child 2' }) .willReturn(childTemplate); + // trigger the parent render, which will trigger the children to re-render + test.component.render(); + // wait for parent Ajax call to finish await waitFor(() => expect(test.element).not.toHaveAttribute('busy')); + // wait for child to start and stop loading - await waitFor(() => expect(childTest1.element).toHaveAttribute('busy')); - await waitFor(() => expect(childTest1.element).not.toHaveAttribute('busy')); - await waitFor(() => expect(childTest2.element).not.toHaveAttribute('busy')); + await waitFor(() => expect(getByTestId(test.element, 'child-component-1')).not.toHaveAttribute('busy')); + await waitFor(() => expect(getByTestId(test.element, 'child-component-2')).not.toHaveAttribute('busy')); expect(test.element).toHaveTextContent('Child number: 1 value "New value for child 1"'); expect(test.element).toHaveTextContent('Child number: 2 value "New value for child 2"'); @@ -364,6 +342,6 @@ describe('Component parent -> child initialization and rendering tests', () => { expect(test.element).not.toHaveTextContent('Child number: 2 value "Original value for child 2"'); // make sure child 2 is in the correct spot expect(test.element.querySelector('#foo')).toHaveTextContent('Child number: 2 value "New value for child 2"'); - expect(test.component.getChildren().size).toEqual(2); + expect(findChildren(test.component).length).toEqual(2); }); }); diff --git a/src/LiveComponent/assets/test/controller/emit.test.ts b/src/LiveComponent/assets/test/controller/emit.test.ts index 50929f2bb4b..15f739ada6e 100644 --- a/src/LiveComponent/assets/test/controller/emit.test.ts +++ b/src/LiveComponent/assets/test/controller/emit.test.ts @@ -64,4 +64,39 @@ describe('LiveController Emit Tests', () => { // wait a tiny bit - enough for a request to be sent if it was going to be await new Promise((resolve) => setTimeout(resolve, 10)); }); + + it('emits event sent back after Ajax call', async () => { + const test = await createTest({ renderCount: 0 }, (data: any) => ` +
+ Render Count: ${data.renderCount} + +
+ `); + + test.expectsAjaxCall() + .serverWillChangeProps((data) => { + data.renderCount = 1; + }) + + test.component.render(); + + test.expectsAjaxCall() + .expectActionCalled('fooAction', { foo: 'bar' }) + .serverWillChangeProps((data) => { + data.renderCount = 2; + }); + + await waitFor(() => expect(test.element).toHaveTextContent('Render Count: 1')); + await waitFor(() => expect(test.element).toHaveTextContent('Render Count: 2')); + }); }); diff --git a/src/LiveComponent/assets/test/controller/loading.test.ts b/src/LiveComponent/assets/test/controller/loading.test.ts index 34b27c6d53c..ee33967ea44 100644 --- a/src/LiveComponent/assets/test/controller/loading.test.ts +++ b/src/LiveComponent/assets/test/controller/loading.test.ts @@ -23,7 +23,6 @@ describe('LiveController data-loading Tests', () => {
I like: ${data.food} Loading... -
`); @@ -38,7 +37,7 @@ describe('LiveController data-loading Tests', () => { // wait for element to hide itself on start up await waitFor(() => expect(getByTestId(test.element, 'loading-element')).not.toBeVisible()); - getByText(test.element, 'Re-Render').click(); + test.component.render(); // element should instantly be visible expect(getByTestId(test.element, 'loading-element')).toBeVisible(); @@ -226,23 +225,31 @@ describe('LiveController data-loading Tests', () => { it('does not trigger loading inside component children', async () => { const childTemplate = (data: any) => ` -
+
Loading... Loading...
`; - const test = await createTest({} , (data: any) => ` + const test = await createTest({renderChild: true} , (data: any) => `
Loading... Loading... - ${childTemplate({})} + ${childTemplate({renderChild: data.renderChild})}
`); test.expectsAjaxCall() // delay so we can check loading + .serverWillChangeProps((data: any) => { + data.renderChild = false; + }) .delayResponse(20); // All showing elements should be hidden / hiding elements should be visible diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index ca31e38ed51..81ef9189e9e 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -705,11 +705,15 @@ describe('LiveController data-model Tests', () => { }); it('does not try to set the value of inputs inside a child component', async () => { - const test = await createTest({ comment: 'cookie', childComment: 'mmmm' }, (data: any) => ` + const test = await createTest({ comment: 'cookie', childComment: 'mmmm', skipChild: false }, (data: any) => `
-
+
@@ -732,6 +736,7 @@ describe('LiveController data-model Tests', () => { // change the data to be extra tricky .serverWillChangeProps((data) => { data.comment = 'i like apples'; + data.skipChild = true; }); await test.component.render(); diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index ced36a3ac3c..df096692a97 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -179,10 +179,10 @@ describe('LiveController rendering Tests', () => { expect(test.element.innerHTML).toContain('I should not be removed'); }); - it('if data-live-id changes, data-live-ignore elements ARE re-rendered', async () => { + it('if id changes, data-live-ignore elements ARE re-rendered', async () => { const test = await createTest({ firstName: 'Ryan', containerId: 'original' }, (data: any) => `
-
+
Inside Ignore Name: ${data.firstName}
diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts index 42ad09a2e9e..a90b37b9b9f 100644 --- a/src/LiveComponent/assets/test/dom_utils.test.ts +++ b/src/LiveComponent/assets/test/dom_utils.test.ts @@ -4,13 +4,13 @@ import { htmlToElement, getModelDirectiveFromElement, elementBelongsToThisComponent, - getElementAsTagText, setValueOnElement } from '../src/dom_utils'; import ValueStore from '../src/Component/ValueStore'; import Component from '../src/Component'; import Backend from '../src/Backend/Backend'; -import {StandardElementDriver} from '../src/Component/ElementDriver'; +import {StimulusElementDriver} from '../src/Component/ElementDriver'; +import { noopElementDriver } from './tools'; const createStore = function(props: any = {}): ValueStore { return new ValueStore(props); @@ -266,21 +266,17 @@ describe('getModelDirectiveFromInput', () => { }); describe('elementBelongsToThisComponent', () => { - const createComponent = (html: string, childComponents: Component[] = []) => { + const createComponent = (html: string) => { const component = new Component( htmlToElement(html), 'some-component', {}, [], - () => [], null, - 'some-id-' + Math.floor((Math.random() * 100)), new Backend(''), - new StandardElementDriver() + new noopElementDriver(), ); - childComponents.forEach((childComponent) => { - component.addChild(childComponent); - }) + component.connect(); return component; }; @@ -305,37 +301,23 @@ describe('elementBelongsToThisComponent', () => { const childComponent = createComponent('
'); childComponent.element.appendChild(targetElement); - const component = createComponent('
', [childComponent]); + const component = createComponent('
'); component.element.appendChild(childComponent.element); - expect(elementBelongsToThisComponent(targetElement, childComponent)).toBeTruthy(); + //expect(elementBelongsToThisComponent(targetElement, childComponent)).toBeTruthy(); expect(elementBelongsToThisComponent(targetElement, component)).toBeFalsy(); }); it('returns false if element *is* a child controller element', () => { const childComponent = createComponent('
'); - const component = createComponent('
', [childComponent]); + const component = createComponent('
'); component.element.appendChild(childComponent.element); expect(elementBelongsToThisComponent(childComponent.element, component)).toBeFalsy(); }); }); -describe('getElementAsTagText', () => { - it('returns self-closing tag correctly', () => { - const element = htmlToElement(''); - - expect(getElementAsTagText(element)).toEqual('') - }); - - it('returns tag text without the innerHTML', () => { - const element = htmlToElement('
Name:
'); - - expect(getElementAsTagText(element)).toEqual('
') - }); -}); - describe('htmlToElement', () => { it('allows to clone HTMLElement', () => { const element = htmlToElement('
bar
'); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 29d1ac97aae..4c70596d646 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -7,6 +7,8 @@ import { BackendAction, BackendInterface, ChildrenFingerprints } from '../src/Ba import BackendRequest from '../src/Backend/BackendRequest'; import { Response } from 'node-fetch'; import { setDeepData } from '../src/data_manipulation_utils'; +import LiveControllerDefault from '../src/live_controller'; +import { ElementDriver } from '../src/Component/ElementDriver'; let activeTests: FunctionalTest[] = []; @@ -42,13 +44,11 @@ class FunctionalTest { template: (props: any) => string; mockedBackend: MockedBackend; - constructor(component: Component, element: HTMLElement, template: (props: any) => string) { + constructor(component: Component, element: HTMLElement, mockedBackend: MockedBackend, template: (props: any) => string) { this.component = component; this.element = element; + this.mockedBackend = mockedBackend; this.template = template; - - this.mockedBackend = new MockedBackend(); - this.component._swapBackend(this.mockedBackend); } expectsAjaxCall = (): MockedAjaxCall => { @@ -350,10 +350,14 @@ class MockedAjaxCall { } } +const mockBackend = new MockedBackend(); + export async function createTest(props: any, template: (props: any) => string): Promise { + LiveControllerDefault.backendFactory = () => mockBackend; + const testData = await startStimulus(template(props)); - const test = new FunctionalTest(testData.controller.component, testData.element, template); + const test = new FunctionalTest(testData.controller.component, testData.element, mockBackend, template); activeTests.push(test); return test; @@ -362,7 +366,7 @@ export async function createTest(props: any, template: (props: any) => string): * An internal way to create a FunctionalTest: useful for child components */ export function createTestForExistingComponent(component: Component): FunctionalTest { - const test = new FunctionalTest(component, component.element, () => ''); + const test = new FunctionalTest(component, component.element, mockBackend, () => ''); activeTests.push(test); return test; @@ -408,7 +412,7 @@ const getControllerElement = (container: HTMLElement): HTMLElement => { return element; }; -const dataToJsonAttribute = (data: any): string => { +export const dataToJsonAttribute = (data: any): string => { const container = document.createElement('div'); container.dataset.foo = JSON.stringify(data); @@ -430,10 +434,11 @@ export function initComponent(props: any = {}, controllerValues: any = {}) { data-live-props-value="${dataToJsonAttribute(props)}" ${controllerValues.debounce ? `data-live-debounce-value="${controllerValues.debounce}"` : ''} ${controllerValues.csrf ? `data-live-csrf-value="${controllerValues.csrf}"` : ''} - ${controllerValues.id ? `data-live-id="${controllerValues.id}"` : ''} + ${controllerValues.id ? `id="${controllerValues.id}"` : ''} ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''} ${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''} - ${controllerValues.browserDispatch ? `data-live-browser-dispatch="${dataToJsonAttribute(controllerValues.browserDispatch)}"` : ''} + ${controllerValues.eventEmit ? `data-live-events-to-emit-value="${dataToJsonAttribute(controllerValues.eventEmit)}"` : ''} + ${controllerValues.browserDispatch ? `data-live-events-to-dispatch-value="${dataToJsonAttribute(controllerValues.browserDispatch)}"` : ''} ${controllerValues.queryMapping ? `data-live-query-mapping-value="${dataToJsonAttribute(controllerValues.queryMapping)}"` : ''} `; } @@ -459,3 +464,26 @@ export function setCurrentSearch(search: string){ export function expectCurrentSearch (){ return expect(decodeURIComponent(window.location.search)); } + +export class noopElementDriver implements ElementDriver { + getBrowserEventsToDispatch(): Array<{ event: string; payload: any }> { + throw new Error('Method not implemented.'); + } + + getComponentProps(): any { + throw new Error('Method not implemented.'); + } + + getEventsToEmit(): Array<{ + event: string; + data: any; + target: string | null; + componentName: string | null + }> { + throw new Error('Method not implemented.'); + } + + getModelName(element: HTMLElement): string | null { + throw new Error('Method not implemented.'); + } +} diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index ad9bb97fffa..36a0264b4b5 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2738,7 +2738,7 @@ current value for all props, except for those that are marked as What if you *do* want your entire child component to re-render (including resetting writable live props) when some value in the parent changes? This -can be done by manually giving your component a ``data-live-id`` attribute +can be done by manually giving your component an ``id`` attribute that will change if the component should be totally re-rendered: .. code-block:: html+twig @@ -2749,11 +2749,11 @@ that will change if the component should be totally re-rendered: {{ component('TodoFooter', { count: todos|length, - 'data-live-id': 'todo-footer-'~todos|length + id: 'todo-footer-'~todos|length }) }}
-In this case, if the number of todos change, then the ``data-live-id`` +In this case, if the number of todos change, then the ``id`` attribute of the component will also change. This signals that the component should re-render itself completely, discarding any writable LiveProp values. @@ -2959,14 +2959,14 @@ Rendering Quirks with List of Elements If you're rendering a list of elements in your component, to help LiveComponents understand which element is which between re-renders (i.e. if something re-orders -or removes some of those elements), you can add a ``data-live-id`` attribute to +or removes some of those elements), you can add a ``id`` attribute to each element .. code-block:: html+twig {# templates/components/Invoice.html.twig #} {% for lineItem in lineItems %} -
+
{{ lineItem.name }}
{% endfor %} @@ -2998,9 +2998,9 @@ to that component: }) }} {% endfor %} -The ``key`` will be used to generate a ``data-live-id`` attribute, +The ``key`` will be used to generate an ``id`` attribute, which will be used to identify each child component. You can -also pass in a ``data-live-id`` attribute directly, but ``key`` is +also pass in a ``id`` attribute directly, but ``key`` is a bit more convenient. .. _rendering-loop-new-element: @@ -3190,39 +3190,23 @@ The system doesn't handle every edge case, so here are some things to keep in mi that change is **lost**: the element will be re-added in its original location during the next re-render. -The Mystical data-live-id Attribute -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The Mystical id Attribute +~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``data-live-id`` attribute is mentioned several times throughout the documentation +The ``id`` attribute is mentioned several times throughout the documentation to solve various problems. It's usually not needed, but can be the key to solving certain complex problems. But what is it? .. note:: - The :ref:`key prop ` is used to create a ``data-live-id`` attribute + The :ref:`key prop ` is used to create a ``id`` attribute on child components. So everything in this section applies equally to the ``key`` prop. -The ``data-live-id`` attribute is a unique identifier for an element or a component. -It's used when a component re-renders and helps Live Components "connect" elements -or components in the existing HTML with the new HTML. The logic works like this: - -Suppose an element or component in the new HTML has a ``data-live-id="some-id"`` attribute. -Then: - -A) If there **is** an element or component with ``data-live-id="some-id"`` in the - existing HTML, then the old and new elements/components are considered to be the - "same". For elements, the new element will be used to update the old element even - if the two elements appear in different places - e.g. like if :ref:`elements are moved ` - or re-ordered. For components, because child components render independently - from their parent, the existing component will be "left alone" and not re-rendered - (unless some ``updateFromParent`` props have changed - see :ref:`child-component-independent-rerender`). - -B) If there is **not** an element or component with ``data-live-id="some-id"`` in - the existing HTML, then the new element or component is considered to be "new". - In both cases, the new element or component will be added to the page. If there - is a component/element with a ``data-live-id`` attribute that is *not* in the - new HTML, that component/element will be removed from the page. +The ``id`` attribute is a unique identifier for an element or a component. +It's used during the morphing process when a component re-renders: it helps the +`morphing library`_ "connect" elements or components in the existing HTML with the new +HTML. Skipping Updating Certain Elements ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -3239,8 +3223,8 @@ an element, that changes is preserved (see :ref:`smart-rerender-algorithm`). .. note:: - To *force* an ignored element to re-render, give its parent element a - ``data-live-id`` attribute. During a re-render, if this value changes, all + To *force* an ignored element to re-render, give its parent element an + ``id`` attribute. During a re-render, if this value changes, all of the children of the element will be re-rendered, even those with ``data-live-ignore``. Overwrite HTML Instead of Morphing @@ -3482,3 +3466,4 @@ bound to Symfony's BC policy for the moment. .. _`Twig Component debug command`: https://symfony.com/bundles/ux-twig-component/current/index.html#debugging-components .. _`PostMount hook`: https://symfony.com/bundles/ux-twig-component/current/index.html#postmount-hook .. _`validation groups`: https://symfony.com/doc/current/form/validation_groups.html +.. _morphing library: https://github.com/bigskysoftware/idiomorph diff --git a/src/LiveComponent/src/EventListener/InterceptChildComponentRenderSubscriber.php b/src/LiveComponent/src/EventListener/InterceptChildComponentRenderSubscriber.php index 552883653a4..4f610351ebf 100644 --- a/src/LiveComponent/src/EventListener/InterceptChildComponentRenderSubscriber.php +++ b/src/LiveComponent/src/EventListener/InterceptChildComponentRenderSubscriber.php @@ -53,8 +53,8 @@ public function preComponentCreated(PreCreateForRenderEvent $event): void $childFingerprints = $parentComponent->getExtraMetadata(self::CHILDREN_FINGERPRINTS_METADATA_KEY); // get the deterministic id for this child, but without incrementing the counter yet - if (isset($event->getInputProps()['data-live-id'])) { - $deterministicId = $event->getInputProps()['data-live-id']; + if (isset($event->getInputProps()['id'])) { + $deterministicId = $event->getInputProps()['id']; } else { $key = $event->getInputProps()[LiveControllerAttributesCreator::KEY_PROP_NAME] ?? null; $deterministicId = $this->getDeterministicIdCalculator()->calculateDeterministicId(increment: false, key: $key); diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php index c1e2b264d3e..0a21f33d438 100644 --- a/src/LiveComponent/src/Test/TestLiveComponent.php +++ b/src/LiveComponent/src/Test/TestLiveComponent.php @@ -42,7 +42,7 @@ public function __construct( ) { $this->client->catchExceptions(false); - $data['attributes']['data-live-id'] ??= 'in-a-real-scenario-it-would-already-have-one---provide-one-yourself-if-needed'; + $data['attributes']['id'] ??= 'in-a-real-scenario-it-would-already-have-one---provide-one-yourself-if-needed'; $mounted = $this->factory->create($this->metadata->getName(), $data); $props = $this->hydrator->dehydrate( diff --git a/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php b/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php index c1c51b1e210..6bd79511fb5 100644 --- a/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php +++ b/src/LiveComponent/src/Util/ChildComponentPartialRenderer.php @@ -51,7 +51,7 @@ public function renderChildComponent(string $deterministicId, string $currentPro /* * The props passed to create this child HAVE changed. * Send back a fake element with: - * * data-live-id + * * id * * data-live-fingerprint-value (new fingerprint) * * data-live-props-value (dehydrated props that "accept updates from parent") */ @@ -69,12 +69,13 @@ public function renderChildComponent(string $deterministicId, string $currentPro $readonlyDehydratedProps = $liveMetadata->getOnlyPropsThatAcceptUpdatesFromParent($props); $readonlyDehydratedProps = $this->getLiveComponentHydrator()->addChecksumToData($readonlyDehydratedProps); - $attributesCollection->setProps($readonlyDehydratedProps); + $attributesCollection->setPropsUpdatedFromParent($readonlyDehydratedProps); $attributes = $attributesCollection->toEscapedArray(); // optional, but these just aren't needed by the frontend at this point unset($attributes['data-controller']); unset($attributes['data-live-url-value']); unset($attributes['data-live-csrf-value']); + unset($attributes['data-live-props-value']); return $this->createHtml($attributes, $childTag); } @@ -88,6 +89,7 @@ private function createHtml(array $attributes, string $childTag): string $attributes = array_map(function ($key, $value) { return sprintf('%s="%s"', $key, $value); }, array_keys($attributes), $attributes); + $attributes[] = 'data-live-preserve="true"'; return sprintf('<%s %s>', $childTag, implode(' ', $attributes), $childTag); } diff --git a/src/LiveComponent/src/Util/LiveAttributesCollection.php b/src/LiveComponent/src/Util/LiveAttributesCollection.php index 43046bf60aa..e73217a8221 100644 --- a/src/LiveComponent/src/Util/LiveAttributesCollection.php +++ b/src/LiveComponent/src/Util/LiveAttributesCollection.php @@ -49,9 +49,10 @@ public function setLiveController(string $componentName): void $this->attributes['data-live-name-value'] = $componentName; } + // TODO rename that public function setLiveId(string $id): void { - $this->attributes['data-live-id'] = $id; + $this->attributes['id'] = $id; } public function setFingerprint(string $fingerprint): void @@ -64,6 +65,11 @@ public function setProps(array $dehydratedProps): void $this->attributes['data-live-props-value'] = $dehydratedProps; } + public function setPropsUpdatedFromParent(array $dehydratedProps): void + { + $this->attributes['data-live-props-updated-from-parent-value'] = $dehydratedProps; + } + public function getProps(): array { if (!\array_key_exists('data-live-props-value', $this->attributes)) { @@ -90,12 +96,12 @@ public function setListeners(array $listeners): void public function setEventsToEmit(array $events): void { - $this->attributes['data-live-emit'] = $events; + $this->attributes['data-live-events-to-emit-value'] = $events; } public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void { - $this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch; + $this->attributes['data-live-events-to-dispatch-value'] = $browserEventsToDispatch; } public function setRequestMethod(string $requestMethod): void diff --git a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php index 81a55a2fe7e..25d89e94b6d 100644 --- a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php +++ b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php @@ -35,7 +35,7 @@ class LiveControllerAttributesCreator /** * Prop name that can be passed into a component to keep it unique in a loop. * - * This is used to generate the unique data-live-id for the child component. + * This is used to generate the unique id for the child component. */ public const KEY_PROP_NAME = 'key'; @@ -89,18 +89,21 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad ]); } - if (!isset($mountedAttributes->all()['data-live-id'])) { + if (!isset($mountedAttributes->all()['id'])) { $id = $deterministicId ?: $this->idCalculator ->calculateDeterministicId(key: $mounted->getInputProps()[self::KEY_PROP_NAME] ?? null); $attributesCollection->setLiveId($id); // we need to add this to the mounted attributes so that it is // will be included in the "attributes" part of the props data. - $mountedAttributes = $mountedAttributes->defaults(['data-live-id' => $id]); + $mountedAttributes = $mountedAttributes->defaults(['id' => $id]); } $liveMetadata = $this->metadataFactory->getMetadata($mounted->getName()); $requestMethod = $liveMetadata->getComponentMetadata()?->get('method') ?? 'post'; - $attributesCollection->setRequestMethod($requestMethod); + // set attribute if needed + if ('post' !== $requestMethod) { + $attributesCollection->setRequestMethod($requestMethod); + } if ($liveMetadata->hasQueryStringBindings()) { $queryMapping = []; diff --git a/src/LiveComponent/tests/Fixtures/templates/components/todo_list.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/todo_list.html.twig index 90c642e2560..cd0489ab795 100644 --- a/src/LiveComponent/tests/Fixtures/templates/components/todo_list.html.twig +++ b/src/LiveComponent/tests/Fixtures/templates/components/todo_list.html.twig @@ -5,7 +5,7 @@ {% for item in items %} {% set componentProps = { text: item.text, textLength: item.text|length } %} {% if includeDataLiveId %} - {% set componentProps = componentProps|merge({'data-live-id': ('todo-item-' ~ loop.index) }) %} + {% set componentProps = componentProps|merge({id: ('todo-item-' ~ loop.index) }) %} {% endif %} {% if loop.index is odd %} {{ component('todo_item', componentProps) }} diff --git a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php index 7162f7fc7c4..7b40968a776 100644 --- a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php @@ -50,7 +50,7 @@ public function testInitLiveComponent(): void $this->assertSame(1, $props['count']); $this->assertArrayHasKey('@checksum', $props); $this->assertArrayHasKey('@attributes', $props); - $this->assertArrayHasKey('data-live-id', $props['@attributes']); + $this->assertArrayHasKey('id', $props['@attributes']); } public function testCanUseCustomAttributesVariableName(): void @@ -99,13 +99,13 @@ public function testItAddsIdAndFingerprintToChildComponent(): void $lis = $ul->children('li'); // deterministic id: should not change, and counter should increase - $this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'0', $lis->first()->attr('data-live-id')); - $this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'1', $lis->last()->attr('data-live-id')); + $this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'0', $lis->first()->attr('id')); + $this->assertSame(self::TODO_ITEM_DETERMINISTIC_PREFIX.'1', $lis->last()->attr('id')); - // the data-live-id attribute also needs to be part of the "props" so that it persists on renders + // the id attribute also needs to be part of the "props" so that it persists on renders $props = json_decode($lis->first()->attr('data-live-props-value'), true); $attributesProps = $props['@attributes']; - $this->assertArrayHasKey('data-live-id', $attributesProps); + $this->assertArrayHasKey('id', $attributesProps); // fingerprints // first and last both have the same length "text" input, and since "textLength" @@ -130,9 +130,9 @@ public function testItDoesNotOverrideDataLiveIdIfSpecified(): void ; $lis = $ul->children('li'); - // deterministic id: is not used: data-live-id was passed in manually - $this->assertSame('todo-item-1', $lis->first()->attr('data-live-id')); - $this->assertSame('todo-item-3', $lis->last()->attr('data-live-id')); + // deterministic id: is not used: id was passed in manually + $this->assertSame('todo-item-1', $lis->first()->attr('id')); + $this->assertSame('todo-item-3', $lis->last()->attr('id')); } public function testQueryStringMappingAttribute() @@ -174,7 +174,7 @@ public function testAbsoluteUrl(): void $this->assertCount(3, $props); $this->assertArrayHasKey('@checksum', $props); $this->assertArrayHasKey('@attributes', $props); - $this->assertArrayHasKey('data-live-id', $props['@attributes']); + $this->assertArrayHasKey('id', $props['@attributes']); $this->assertArrayHasKey('count', $props); $this->assertSame($props['count'], 0); } @@ -209,7 +209,7 @@ public function testAbsoluteUrlWithLiveQueryProp() $this->assertCount(3, $props); $this->assertArrayHasKey('@checksum', $props); $this->assertArrayHasKey('@attributes', $props); - $this->assertArrayHasKey('data-live-id', $props['@attributes']); + $this->assertArrayHasKey('id', $props['@attributes']); $this->assertArrayHasKey('count', $props); $this->assertSame($props['count'], 2); } diff --git a/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php index b341e9cd6b2..d243ccba1e8 100644 --- a/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php @@ -35,7 +35,7 @@ public function testItSetsDeferredTemplateIfLiveIdNotPassed(): void $this->assertSame('live:connect->live#$render', $div->attr('data-action')); $component = $this->mountComponent('deferred_component', [ - 'data-live-id' => $div->attr('data-live-id'), + 'id' => $div->attr('id'), ]); $dehydrated = $this->dehydrateComponent($component); @@ -62,7 +62,7 @@ public function testItIncludesGivenTemplateWhileLoadingDeferredComponent(): void $this->assertSame('I\'m loading a reaaaally slow live component', trim($div->html())); $component = $this->mountComponent('deferred_component', [ - 'data-live-id' => $div->attr('data-live-id'), + 'id' => $div->attr('id'), ]); $dehydrated = $this->dehydrateComponent($component); diff --git a/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php index 90b8fd96070..3dfd77bb270 100644 --- a/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php @@ -86,19 +86,19 @@ public function testItRendersNewPropWhenFingerprintDoesNotMatch(): void ->use(function (AbstractBrowser $browser) { $content = $browser->getResponse()->getContent(); - // 1st and 3rd render empty - // fingerprint changed in 2nd, so it renders new fingerprint + props + // 1st renders empty + // fingerprint changed in 2nd & 3rd, so it renders new fingerprint + props $this->assertStringContainsString(sprintf( - '
  • ', + '
  • ', AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX ), $content); // new props are JUST the "textLength" + a checksum for it specifically $this->assertStringContainsString(sprintf( - '
  • ', + '
  • ', AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX_EMBEDDED ), $content); $this->assertStringContainsString(sprintf( - '
  • ', + '
  • ', AddLiveAttributesSubscriberTest::TODO_ITEM_DETERMINISTIC_PREFIX ), $content); }); @@ -129,12 +129,12 @@ public function testItUsesKeysToRenderChildrenLiveIds(): void ->assertHtml() ->assertElementCount('ul li', 3) // check for the live-id we expect based on the key - ->assertContains('data-live-id="live-521026374-the-key0"') + ->assertContains('id="live-521026374-the-key0"') ->assertNotContains('key="the-key0"') ->visit($urlWithChangedFingerprints) - ->assertContains('
  • ') + ->assertContains('
  • ') // this one is changed, so it renders a full element - ->assertContains('
  • assertContains('
  • filter('div'); - $browserDispatch = $div->attr('data-live-browser-dispatch'); + $browserDispatch = $div->attr('data-live-events-to-dispatch-value'); $this->assertNotNull($browserDispatch); $browserDispatchData = json_decode($browserDispatch, true); $this->assertSame([ diff --git a/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php b/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php index 254e09dc32f..2b5ea2371e4 100644 --- a/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php +++ b/src/LiveComponent/tests/Integration/EventListener/DataModelPropsSubscriberTest.php @@ -34,7 +34,7 @@ public function testDataModelPropsAreSharedToChild(): void // Normally createAndRender is always called from within a Template via the ComponentExtension. // To avoid that the DeterministicTwigIdCalculator complains that there's no Template // to base the live id on, we'll add this dummy one, so it gets skipped. - 'attributes' => ['data-live-id' => 'dummy-live-id'], + 'attributes' => ['id' => 'dummy-live-id'], ]); $this->assertStringContainsString('', $html); @@ -53,7 +53,7 @@ public function testDataModelPropsAreAvailableInEmbeddedComponents(): void $renderer = self::getContainer()->get('ux.twig_component.component_renderer'); $html = $renderer->createAndRender('parent_component_data_model', [ - 'attributes' => ['data-live-id' => 'dummy-live-id'], + 'attributes' => ['id' => 'dummy-live-id'], ]); $this->assertStringContainsString('', $html); diff --git a/src/LiveComponent/tests/Integration/Twig/LiveComponentRuntimeTest.php b/src/LiveComponent/tests/Integration/Twig/LiveComponentRuntimeTest.php index 0c19c6a7bb6..350ce2bfde0 100644 --- a/src/LiveComponent/tests/Integration/Twig/LiveComponentRuntimeTest.php +++ b/src/LiveComponent/tests/Integration/Twig/LiveComponentRuntimeTest.php @@ -31,7 +31,7 @@ public function testGetComponentUrl(): void 'prop1' => null, 'prop2' => new \DateTime('2022-10-06-0'), 'prop3' => 'howdy', - 'attributes' => ['data-live-id' => 'in-a-real-scenario-it-would-already-have-one'], + 'attributes' => ['id' => 'in-a-real-scenario-it-would-already-have-one'], ]); $this->assertStringStartsWith('/_components/component1?props=%7B%22prop1%22:null,%22prop2%22:%222022-10-06T00:00:00%2B00:00%22,%22prop3%22:%22howdy%22,%22', $url); diff --git a/src/LiveComponent/tests/LiveComponentTestHelper.php b/src/LiveComponent/tests/LiveComponentTestHelper.php index 142528c55b6..831a7f308ce 100644 --- a/src/LiveComponent/tests/LiveComponentTestHelper.php +++ b/src/LiveComponent/tests/LiveComponentTestHelper.php @@ -43,8 +43,8 @@ private function getComponent(string $name): object private function mountComponent(string $name, array $data = [], $addDummyLiveId = true): MountedComponent { - if ($addDummyLiveId && empty($data['attributes']['data-live-id'])) { - $data['attributes']['data-live-id'] = 'in-a-real-scenario-it-would-already-have-one'; + if ($addDummyLiveId && empty($data['attributes']['id'])) { + $data['attributes']['id'] = 'in-a-real-scenario-it-would-already-have-one'; } return $this->factory()->create($name, $data); diff --git a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php index eea84503a9f..ba933cf9aa8 100644 --- a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php +++ b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php @@ -51,13 +51,13 @@ public function testToEscapedArray(): void $expected = [ 'data-controller' => 'live', 'data-live-name-value' => 'my-component', - 'data-live-id' => 'the-live-id', + 'id' => 'the-live-id', 'data-live-fingerprint-value' => 'the-fingerprint', 'data-live-props-value' => '{"the":"props"}', 'data-live-url-value' => 'the-live-url', 'data-live-csrf-value' => 'the-csrf-token', 'data-live-listeners-value' => '{"event_name":"theActionName"}', - 'data-live-emit' => '[{"event":"event_name1","data":{"the":"data"},"target":"up","componentName":"the-component"},{"event":"event_name2","data":{"the":"data"},"target":null,"componentName":null}]', + 'data-live-events-to-emit-value' => '[{"event":"event_name1","data":{"the":"data"},"target":"up","componentName":"the-component"},{"event":"event_name2","data":{"the":"data"},"target":null,"componentName":null}]', 'data-live-query-mapping-value' => '{"foo":{"name":"foo"},"bar":{"name":"bar"}}', ]; diff --git a/ux.symfony.com/templates/components/LiveMemory/Card.html.twig b/ux.symfony.com/templates/components/LiveMemory/Card.html.twig index 08b3c661657..c7239866cdc 100644 --- a/ux.symfony.com/templates/components/LiveMemory/Card.html.twig +++ b/ux.symfony.com/templates/components/LiveMemory/Card.html.twig @@ -1,5 +1,5 @@