Skip to content

Commit

Permalink
[LiveComponent] Trigger model:set if a prop changes on the server
Browse files Browse the repository at this point in the history
  • Loading branch information
weaverryan committed Oct 25, 2023
1 parent 262db2b commit d82ee7a
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 57 deletions.
32 changes: 27 additions & 5 deletions src/LiveComponent/assets/src/Component/ValueStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { normalizeModelName } from '../string_utils';
export default class {
/**
* Original, read-only props that represent the original component state.
*
* @private
*/
private props: any = {};

Expand Down Expand Up @@ -104,10 +102,14 @@ export default class {
/**
* Called when an update request finishes successfully.
*/
reinitializeAllProps(props: any): void {
reinitializeAllProps(props: any): string[] {
const changedProps = this.deepDiff(props);

this.props = props;
this.updatedPropsFromParent = {};
this.pendingProps = {};

return changedProps;
}

/**
Expand Down Expand Up @@ -138,8 +140,6 @@ export default class {
for (const [key, value] of Object.entries(props)) {
const currentValue = this.get(key);

// if the readonly identifier is different, then overwrite the
// prop entirely
if (currentValue !== value) {
changed = true;
}
Expand All @@ -151,4 +151,26 @@ export default class {

return changed;
}

private deepDiff(newObj: any, prefix = '', changedProps: string[] = []): string[] {
for (const [key, value] of Object.entries(newObj)) {
const currentPath = prefix ? `${prefix}.${key}` : key;
const currentValue = this.get(currentPath);

if (currentValue !== value) {
if (typeof currentValue !== 'object' || typeof value !== 'object') {
// if a prop is dirty, the prop hasn't changed, because the dirty value will take precedence
if (this.dirtyProps[currentPath] !== undefined) {
continue;
}

changedProps.push(currentPath);
} else {
this.deepDiff(value, currentPath, changedProps);
}
}
}

return changedProps;
}
}
6 changes: 5 additions & 1 deletion src/LiveComponent/assets/src/Component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ export default class Component {
}

const newProps = this.elementDriver.getComponentProps(newElement);
this.valueStore.reinitializeAllProps(newProps);
const changedProps = this.valueStore.reinitializeAllProps(newProps);

const eventsToEmit = this.elementDriver.getEventsToEmit(newElement);
const browserEventsToDispatch = this.elementDriver.getBrowserEventsToDispatch(newElement);
Expand All @@ -497,6 +497,10 @@ export default class Component {
this.valueStore.set(modelName, modifiedModelValues[modelName]);
});

changedProps.forEach((modelName) => {
this.hooks.triggerHook('model:set', modelName, this.valueStore.get(modelName), this);
});

eventsToEmit.forEach(({ event, data, target, componentName }) => {
if (target === 'up') {
this.emitUp(event, data, componentName);
Expand Down
192 changes: 147 additions & 45 deletions src/LiveComponent/assets/test/Component/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,74 +5,176 @@ import BackendRequest from '../../src/Backend/BackendRequest';
import { Response } from 'node-fetch';
import { waitFor } from '@testing-library/dom';
import BackendResponse from '../../src/Backend/BackendResponse';
import { dataToJsonAttribute } from '../tools';

class ComponentTest {
component: Component;
backend: BackendInterface;

calledActions: BackendAction[] = [];
mockedResponses: string[] = [];
currentMockedResponse = 0;

constructor(props: any) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const componentTest = this;
this.backend = {
makeRequest(props: any, actions: BackendAction[]): BackendRequest {
componentTest.calledActions = actions;

if (!componentTest.mockedResponses[componentTest.currentMockedResponse]) {
throw new Error(`No mocked response for request #${componentTest.currentMockedResponse}`);
}
const html = componentTest.mockedResponses[componentTest.currentMockedResponse];
componentTest.currentMockedResponse++;

return new BackendRequest(
// @ts-ignore Response doesn't quite match the underlying interface
new Promise((resolve) => resolve(new Response(html, {
headers: {
'Content-Type': 'application/vnd.live-component+html',
}
}))),
[],
[],
);
},
};

this.component = new Component(
document.createElement('div'),
'test-component',
props,
[],
() => [],
null,
null,
this.backend,
new StandardElementDriver(),
);
}

interface MockBackend extends BackendInterface {
actions: BackendAction[],
}

const makeTestComponent = (): { component: Component, backend: MockBackend } => {
const backend: MockBackend = {
actions: [],
makeRequest(data: any, actions: BackendAction[]): BackendRequest {
this.actions = actions;

return new BackendRequest(
// @ts-ignore Response doesn't quite match the underlying interface
new Promise((resolve) => resolve(new Response('<div data-live-props-value="{}"></div>'))),
[],
[]
)
}
addMockResponse(html: string): void {
this.mockedResponses.push(html);
}

const component = new Component(
document.createElement('div'),
'test-component',
{ firstName: '' },
[],
() => [],
null,
null,
backend,
new StandardElementDriver()
);

return {
component,
backend
getPendingResponseCount(): number {
return this.mockedResponses.length - this.currentMockedResponse;
}
}

let currentTest: ComponentTest|null = null;
const createTest = (props: any): ComponentTest => {
return currentTest = new ComponentTest(props);
};

describe('Component class', () => {
afterEach(() => {
if (currentTest) {
if (currentTest.getPendingResponseCount() > 0) {
throw new Error(`Test finished with ${currentTest.getPendingResponseCount()} pending responses`);
}
}
currentTest = null;
});

describe('set() method', () => {
it('returns a Promise that eventually resolves', async () => {
const { component } = makeTestComponent();
const test = createTest({
firstName: '',
});
test.addMockResponse('<div data-live-props-value="{}"></div>');

let backendResponse: BackendResponse|null = null;

// set model but no re-render
const promise = component.set('firstName', 'Ryan', false);
const promise = test.component.set('firstName', 'Ryan', false);
// when this promise IS finally resolved, set the flag to true
promise.then((response) => backendResponse = response);
// it should not have happened yet
// even if we wait for a potential response to resolve, it won't resolve the promise yet
await (new Promise(resolve => setTimeout(resolve, 10)));
expect(backendResponse).toBeNull();

// set model WITH re-render
component.set('firstName', 'Kevin', true);
// it's still not *instantly* resolve - it'll
test.component.set('firstName', 'Kevin', true);
// it's still not *instantly* resolved
expect(backendResponse).toBeNull();
await waitFor(() => expect(backendResponse).not.toBeNull());
// @ts-ignore
expect(await backendResponse?.getBody()).toEqual('<div data-live-props-value="{}"></div>');
});

it('triggers the model:set hook', async () => {
const test = createTest({
firstName: '',
});

let hookCalled = false;
let actualModel: string|null = null;
let actualValue: string|null = null;
let actualComponent: Component|null = null;
test.component.on('model:set', (model, value, theComponent) => {
hookCalled = true;
actualModel = model;
actualValue = value;
actualComponent = theComponent;
});
test.component.set('firstName', 'Ryan', false);
expect(hookCalled).toBe(true);
expect(actualModel).toBe('firstName');
expect(actualValue).toBe('Ryan');
expect(actualComponent).toBe(test.component);
});
});

describe('render() method', () => {
it('triggers model:set hook if a model changes on the server', async () => {
const test = createTest({
firstName: '',
product: {
id: 5,
name: 'cool stuff',
},
lastName: '',
});

const newProps = {
firstName: 'Ryan',
lastName: 'Bond',
product: {
id: 5,
name: 'purple stuff',
},
};
test.addMockResponse(`<div data-controller="live" data-live-props-value="${dataToJsonAttribute(newProps)}"></div>`);

const promise = test.component.render();

// During the request, change lastName to make it a "dirty change"
// The new value from the server is effectively ignored, and so no
// model:set hook should be triggered
test.component.set('lastName', 'dirty change', false);

const hookModels: string[] = [];
test.component.on('model:set', (model) => {
hookModels.push(model);
});

await promise;

expect(hookModels).toEqual(['firstName', 'product.name']);
});
});

describe('Proxy wrapper', () => {
const makeDummyComponent = (): { proxy: Component, backend: MockBackend } => {
const { backend, component} = makeTestComponent();
const makeDummyComponent = (): { proxy: Component, test: ComponentTest } => {
const test = createTest({
firstName: '',
});

return {
proxy: proxifyComponent(component),
backend
proxy: proxifyComponent(test.component),
test,
}
}

Expand Down Expand Up @@ -108,16 +210,16 @@ describe('Component class', () => {
});

it('calls an action on a component', async () => {
const { proxy, backend } = makeDummyComponent();
const { proxy, test } = makeDummyComponent();
// @ts-ignore
proxy.save({ foo: 'bar', secondArg: 'secondValue' });

// ugly: the action delays for 0ms, so we just need a TINy
// delay here before we start asserting
await (new Promise(resolve => setTimeout(resolve, 5)));
expect(backend.actions).toHaveLength(1);
expect(backend.actions[0].name).toBe('save');
expect(backend.actions[0].args).toEqual({ foo: 'bar', secondArg: 'secondValue' });
expect(test.calledActions).toHaveLength(1);
expect(test.calledActions[0].name).toBe('save');
expect(test.calledActions[0].args).toEqual({ foo: 'bar', secondArg: 'secondValue' });
});
});
});
37 changes: 37 additions & 0 deletions src/LiveComponent/assets/test/ValueStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,41 @@ describe('ValueStore', () => {
expect(store.getUpdatedPropsFromParent()).toEqual(changed ? newProps : {});
});
});

it('returns the correctly changed models from reinitializeAllProps()', () => {
const store = new ValueStore({
firstName: 'Ryan',
lastName: 'Weaver',
user: {
firstName: 'Ryan',
lastName: 'Weaver',
},
product: 5,
color: 'purple',
});

// "dirty" props are not returned because the dirty value remains
// and overrides the new value
store.set('color', 'orange');

const changedProps = store.reinitializeAllProps({
firstName: 'Ryan',
lastName: 'Bond',
user: {
firstName: 'Ryan',
lastName: 'Bond',
},
product: {
id: 5,
name: 'cool stuff',
},
color: 'green'
});

expect(changedProps).toEqual([
'lastName',
'user.lastName',
'product',
]);
});
});
2 changes: 1 addition & 1 deletion src/LiveComponent/assets/test/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,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);

Expand Down
2 changes: 2 additions & 0 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,8 @@ The following hooks are available (along with the arguments that are passed):
* ``loading.state:started`` args ``(element: HTMLElement, request: BackendRequest)``
* ``loading.state:finished`` args ``(element: HTMLElement)``
* ``model:set`` args ``(model: string, value: any, component: Component)``
Triggered immediately when a prop is changed via any means on the frontend
or a prop is changed on the server during a re-render/action.

Adding a Stimulus Controller to your Component Root Element
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
Loading

0 comments on commit d82ee7a

Please sign in to comment.