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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/valdi_modules/src/valdi/valdi_core/src/Remember.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getRenderer } from './Renderer';

export function remember<T>(factory: () => T, ...keys: unknown[]): T {
return getRenderer().remember(factory, keys);
}
76 changes: 71 additions & 5 deletions src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ import { trace } from './utils/Trace';
const EMPTY_OBJECT = Object.freeze({});
const EMPTY_ARRAY = Object.freeze([]) as [];

interface RememberSlot<T = unknown> {
value: T;
keys: unknown[];
}

interface RememberState {
slots: RememberSlot[];
slotIndex: number;
}

interface NodeChildren<T> {
childByKey: { [key: string]: T };
children: T[];
Expand Down Expand Up @@ -67,6 +77,8 @@ interface VirtualNode extends Node {

// Bridge instance, will be set if the virtual node was requested externally.
bridge?: VirtualNodeBridge;

rememberState?: RememberState;
}

interface RenderedElement {
Expand Down Expand Up @@ -414,6 +426,21 @@ function getNodeDescription(node: VirtualNode): string {
return 'unknown node';
}

function rememberKeysEqual(left: unknown[], right: unknown[]): boolean {
const length = left.length;
if (length !== right.length) {
return false;
}

for (let i = 0; i < length; i++) {
if (!Object.is(left[i], right[i])) {
return false;
}
}

return true;
}

interface RendererLogInfo {
treeId: string;
componentRerendersCount: number;
Expand Down Expand Up @@ -921,7 +948,7 @@ export class Renderer implements IRenderer {
const currentNode = this.getCurrentNode();

const resolvedKey = key || nodePrototype.id;
const resolvedNode = this.resolveVirtualNode(currentNode, resolvedKey, undefined, undefined);
const resolvedNode = this.resolveVirtualNode(currentNode, resolvedKey, undefined, undefined, undefined);

let justCreated = false;

Expand Down Expand Up @@ -1050,6 +1077,9 @@ export class Renderer implements IRenderer {
if (node.children) {
node.children.insertionIndex = 0;
}
if (node.rememberState) {
node.rememberState.slotIndex = 0;
}
node.lastRenderId = this.renderId;

this.currentNode = node;
Expand Down Expand Up @@ -1375,10 +1405,12 @@ export class Renderer implements IRenderer {

private resolveVirtualNode(
parent: VirtualNode,
resolvedKey: string,
key: string,
duplicateKeyIndex: number | undefined,
componentConstructor: ComponentConstructor<IComponent> | undefined,
componentPrototype: ComponentPrototype | undefined,
): VirtualNode {
const resolvedKey = duplicateKeyIndex !== undefined ? `${key}-${duplicateKeyIndex}` : key;
let resolvedNode: VirtualNode | undefined;
let children = parent.children;
if (!children) {
Expand Down Expand Up @@ -1429,7 +1461,8 @@ export class Renderer implements IRenderer {
const duplicateKeyIndex = children.insertionIndex - resolvedNode.parentIndex + 1;
return this.resolveVirtualNode(
parent,
resolvedKey + duplicateKeyIndex,
key,
duplicateKeyIndex,
componentConstructor,
componentPrototype,
);
Expand Down Expand Up @@ -1457,6 +1490,9 @@ export class Renderer implements IRenderer {

private destroyVirtualNode(node: VirtualNode, parentElementWasDestroyed: boolean) {
node.parent = undefined;
if (node.rememberState) {
node.rememberState = undefined;
}

if (node.element) {
const element = node.element;
Expand Down Expand Up @@ -1611,6 +1647,36 @@ export class Renderer implements IRenderer {
}
}

remember<T>(factory: () => T, keys: unknown[]): T {
const currentNode = this.currentNode;
if (!currentNode) {
throw Error('Cannot call this outside of a onRender callback');
}

let rememberState = currentNode.rememberState;
if (!rememberState) {
rememberState = {
slots: [],
slotIndex: 0,
};
currentNode.rememberState = rememberState;
}

const slotIndex = rememberState.slotIndex++;
const slots = rememberState.slots;
const slot = slots[slotIndex] as RememberSlot<T> | undefined;
if (slot && rememberKeysEqual(slot.keys, keys)) {
return slot.value;
}

const value = factory();
slots[slotIndex] = {
value,
keys: keys.length ? keys : EMPTY_ARRAY,
};
return value;
}

hasInjectedAttribute(name: string): boolean {
const component = this.getCurrentComponent();
const attributes = component.injectedAttributes;
Expand Down Expand Up @@ -1835,7 +1901,7 @@ export class Renderer implements IRenderer {
const resolvedKey = key || prototype.id;

const parent = this.getCurrentNode();
const resolvedNode = this.resolveVirtualNode(parent, resolvedKey, ctr, prototype);
const resolvedNode = this.resolveVirtualNode(parent, resolvedKey, undefined, ctr, prototype);

let justCreated = false;

Expand Down Expand Up @@ -2161,7 +2227,7 @@ export class Renderer implements IRenderer {

beginSlot<F extends AnyRenderFunction>(slotData: ComponentSlotData<F>) {
const parentNode = this.getCurrentNode();
const node = this.resolveVirtualNode(parentNode, slotData.nodeKey, undefined, undefined);
const node = this.resolveVirtualNode(parentNode, slotData.nodeKey, undefined, undefined, undefined);
slotData.node = node;
node.slot = true;
this.pushVirtualNode(node);
Expand Down
212 changes: 212 additions & 0 deletions src/valdi_modules/src/valdi/valdi_test/test/Remember.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { NodePrototype } from 'valdi_core/src/NodePrototype';
import { remember } from 'valdi_core/src/Remember';
import { Renderer } from 'valdi_core/src/Renderer';
import 'jasmine/src/jasmine';
import { RendererTestDelegate } from './RendererTestDelegate';

interface TestVirtualNode {
rememberState?: unknown;
children?: {
children: TestVirtualNode[];
};
}

interface RememberedValue {
id: number;
itemKey?: string;
}

function makeRenderer(): Renderer {
return new Renderer('', undefined, new RendererTestDelegate());
}

function makeNodePrototype(viewClass: string): NodePrototype {
return new NodePrototype(viewClass, viewClass);
}

function hasRememberStorage(node: TestVirtualNode): boolean {
if (Object.prototype.hasOwnProperty.call(node, 'rememberState')) {
return true;
}

const children = node.children?.children;
if (!children) {
return false;
}

for (const child of children) {
if (hasRememberStorage(child)) {
return true;
}
}

return false;
}

describe('remember', () => {
it('does not allocate remember storage when unused', () => {
const renderer = makeRenderer();
const rootPrototype = makeNodePrototype('view');

renderer.begin();
renderer.beginElement(rootPrototype);
renderer.endElement();
renderer.end();

expect(hasRememberStorage((renderer as any).nodeTree)).toBe(false);
});

it('persists values with no keys across renders of the same VirtualNode', () => {
const renderer = makeRenderer();
const rootPrototype = makeNodePrototype('view');
let factoryCalls = 0;
let renderedValue: RememberedValue | undefined;

function render() {
renderer.begin();
renderer.beginElement(rootPrototype);
renderedValue = remember(() => ({ id: ++factoryCalls }));
renderer.endElement();
renderer.end();
}

render();
const firstValue = renderedValue;
render();

expect(renderedValue).toBe(firstValue);
expect(factoryCalls).toBe(1);
});

it('compares keys with Object.is', () => {
const renderer = makeRenderer();
const rootPrototype = makeNodePrototype('view');
let factoryCalls = 0;
let renderedValue = 0;

function render(key: unknown) {
renderer.begin();
renderer.beginElement(rootPrototype);
renderedValue = remember(() => ++factoryCalls, key);
renderer.endElement();
renderer.end();
}

render(NaN);
expect(renderedValue).toBe(1);

render(NaN);
expect(renderedValue).toBe(1);
expect(factoryCalls).toBe(1);

render(0);
expect(renderedValue).toBe(2);

render(-0);
expect(renderedValue).toBe(3);
expect(factoryCalls).toBe(3);
});

it('keeps multiple calls in the same VirtualNode independent', () => {
const renderer = makeRenderer();
const rootPrototype = makeNodePrototype('view');
let firstFactoryCalls = 0;
let secondFactoryCalls = 0;
let firstValue: RememberedValue | undefined;
let secondValue: RememberedValue | undefined;

function render() {
renderer.begin();
renderer.beginElement(rootPrototype);
firstValue = remember(() => ({ id: ++firstFactoryCalls }), 'same-key');
secondValue = remember(() => ({ id: ++secondFactoryCalls }), 'same-key');
renderer.endElement();
renderer.end();
}

render();
const initialFirstValue = firstValue;
const initialSecondValue = secondValue;
render();

expect(firstValue).toBe(initialFirstValue);
expect(secondValue).toBe(initialSecondValue);
expect(firstValue).not.toBe(secondValue);
expect(firstFactoryCalls).toBe(1);
expect(secondFactoryCalls).toBe(1);
});

it('scopes remembered values to keyed VirtualNodes across sibling reorders', () => {
const renderer = makeRenderer();
const rootPrototype = makeNodePrototype('view');
const itemPrototype = makeNodePrototype('label');
let nextId = 0;

function render(keys: string[]): { [key: string]: RememberedValue } {
const values: { [key: string]: RememberedValue } = {};

renderer.begin();
renderer.beginElement(rootPrototype);
for (const key of keys) {
renderer.beginElement(itemPrototype, key);
values[key] = remember(() => ({ id: ++nextId, itemKey: key }));
renderer.endElement();
}
renderer.endElement();
renderer.end();

return values;
}

const firstRender = render(['one', 'two', 'three']);
const secondRender = render(['three', 'one', 'two']);

expect(secondRender.one).toBe(firstRender.one);
expect(secondRender.two).toBe(firstRender.two);
expect(secondRender.three).toBe(firstRender.three);
expect(nextId).toBe(3);
});

it('forgets values when a VirtualNode is removed', () => {
const renderer = makeRenderer();
const rootPrototype = makeNodePrototype('view');
const itemPrototype = makeNodePrototype('label');
let factoryCalls = 0;

function render(showItem: boolean): RememberedValue | undefined {
let value: RememberedValue | undefined;

renderer.begin();
renderer.beginElement(rootPrototype);
if (showItem) {
renderer.beginElement(itemPrototype, 'item');
value = remember(() => ({ id: ++factoryCalls }));
renderer.endElement();
}
renderer.endElement();
renderer.end();

return value;
}

const firstValue = render(true);
render(false);
const secondValue = render(true);

expect(firstValue).toBeDefined();
expect(secondValue).toBeDefined();
expect(secondValue).not.toBe(firstValue);
expect(factoryCalls).toBe(2);
});

it('throws when called outside a render', () => {
let error: unknown;
try {
remember(() => 1);
} catch (err: unknown) {
error = err;
}

expect(String(error)).toContain('Cannot call this outside of a onRender callback');
});
});
Loading
Loading