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
83 changes: 76 additions & 7 deletions packages/super-editor/src/editors/v1/core/CommandService.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,18 @@ export class CommandService {
* editor.commands.insertText('hello'); // falls back to editor.dispatch
*/
get commands() {
const { editor, state } = this;
const { editor, state, rawCommands } = this;
const { view } = editor;
const { tr } = state;
const props = this.createProps(tr);

const entries = Object.entries(this.rawCommands).map(([name, command]) => {
/** @type {(...args: any[]) => boolean} */
const method = (...args) => {
/**
* @param {string} name
* @returns {(...args: any[]) => boolean}
*/
const buildMethod = (name) => {
const command = rawCommands[name];
return (...args) => {
const fn = command(...args)(props);

if (!tr.getMeta('preventDispatch')) {
Expand All @@ -73,11 +77,76 @@ export class CommandService {

return fn;
};
};

return [name, method];
});
const deletedCommandNames = new Set();

/** @param {PropertyKey} name */
const isRegisteredCommandName = (name) =>
typeof name === 'string' && Object.prototype.propertyIsEnumerable.call(rawCommands, name);

/** @param {PropertyKey} name */
const isCommandName = (name) => isRegisteredCommandName(name) && !deletedCommandNames.has(name);

return /** @type {import('./commands/types/index.js').EditorCommands} */ Object.fromEntries(entries);
/**
* @param {Record<PropertyKey, any>} target
* @param {PropertyKey} name
*/
const materializeCommand = (target, name) => {
if (Object.prototype.hasOwnProperty.call(target, name) || !isCommandName(name)) return;
Object.defineProperty(target, name, {
configurable: true,
enumerable: true,
writable: true,
value: buildMethod(/** @type {string} */ (name)),
});
};

/** @param {Record<PropertyKey, any>} target */
const materializeAllCommands = (target) => {
Object.keys(rawCommands).forEach((name) => materializeCommand(target, name));
};

return /** @type {import('./commands/types/index.js').EditorCommands} */ (
new Proxy(
{},
{
get: (target, name, receiver) => {
materializeCommand(target, name);
return Reflect.get(target, name, receiver);
},
has: (target, name) => isCommandName(name) || Reflect.has(target, name),
ownKeys: (target) => {
materializeAllCommands(target);
const commandNames = Object.keys(rawCommands).filter((name) => !deletedCommandNames.has(name));
const commandNameSet = new Set(commandNames);
const targetKeys = Reflect.ownKeys(target).filter(
(name) => typeof name !== 'string' || !commandNameSet.has(name),
);
return [...commandNames, ...targetKeys];
},
getOwnPropertyDescriptor: (target, name) => {
materializeCommand(target, name);
return Reflect.getOwnPropertyDescriptor(target, name);
},
defineProperty: (target, name, descriptor) => {
materializeCommand(target, name);
return Reflect.defineProperty(target, name, descriptor);
},
deleteProperty: (target, name) => {
const deleted = Reflect.deleteProperty(target, name);
if (deleted && isRegisteredCommandName(name)) {
deletedCommandNames.add(name);
}
return deleted;
},
preventExtensions: (target) => {
materializeAllCommands(target);
return Reflect.preventExtensions(target);
},
},
)
);
}

/**
Expand Down
190 changes: 190 additions & 0 deletions packages/super-editor/src/editors/v1/core/CommandService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,4 +548,194 @@ describe('CommandService', () => {
expect(editorDispatch).toHaveBeenNthCalledWith(2, tr2);
});
});

describe('lazy command access (perf)', () => {
const makeTrackedRawCommands = (names, reads, enumerations = []) => {
const target = {};
for (const name of names) {
target[name] = () => () => true;
}
return new Proxy(target, {
get(t, key, receiver) {
if (typeof key === 'string' && key in t) reads.push(key);
return Reflect.get(t, key, receiver);
},
ownKeys(t) {
enumerations.push(true);
return Reflect.ownKeys(t);
},
});
};

it('reads only the accessed command, not every registered command', () => {
const reads = [];
const enumerations = [];
editor.extensionService.commands = makeTrackedRawCommands(['alpha', 'beta', 'gamma'], reads, enumerations);

const service = new CommandService({ editor });
reads.length = 0;

const result = service.commands.alpha();

expect(result).toBe(true);
expect(reads).toEqual(['alpha']);
expect(enumerations).toEqual([]);
});

it('reuses a command method within the same commands object', () => {
const reads = [];
editor.extensionService.commands = makeTrackedRawCommands(['alpha'], reads);

const service = new CommandService({ editor });
reads.length = 0;
const commands = service.commands;

expect(commands.alpha).toBe(commands.alpha);
expect(reads).toEqual(['alpha']);
});

it('preserves ordinary Object prototype behavior', () => {
editor.extensionService.commands = { alpha: () => () => true };

const service = new CommandService({ editor });
const commands = service.commands;

expect(commands.hasOwnProperty('alpha')).toBe(true);
expect(String(commands)).toBe('[object Object]');
});

it('preserves command property descriptors', () => {
editor.extensionService.commands = { alpha: () => () => true };

const service = new CommandService({ editor });

expect(Object.getOwnPropertyDescriptor(service.commands, 'alpha')).toMatchObject({
writable: true,
enumerable: true,
configurable: true,
});
});

it('allows a command method to be replaced on the commands object', () => {
editor.extensionService.commands = { alpha: () => () => true };

const service = new CommandService({ editor });
const commands = service.commands;
const replacement = vi.fn(() => false);

commands.alpha = replacement;

expect(commands.alpha).toBe(replacement);
});

it('allows a command method to be deleted from the commands object', () => {
editor.extensionService.commands = { alpha: () => () => true };

const service = new CommandService({ editor });
const commands = service.commands;

delete commands.alpha;

expect(commands.alpha).toBeUndefined();
expect('alpha' in commands).toBe(false);
expect(Object.keys(commands)).toEqual([]);
});

it('allows a command method to be defined on the commands object', () => {
editor.extensionService.commands = { alpha: () => () => true };

const service = new CommandService({ editor });
const commands = service.commands;
const replacement = vi.fn(() => false);

Object.defineProperty(commands, 'alpha', {
value: replacement,
});

expect(commands.alpha).toBe(replacement);
expect(Object.getOwnPropertyDescriptor(commands, 'alpha')).toMatchObject({
writable: true,
enumerable: true,
configurable: true,
});
});

it('supports freezing the commands object', () => {
editor.extensionService.commands = {
alpha: () => () => true,
beta: () => () => true,
};

const service = new CommandService({ editor });
const commands = service.commands;

expect(() => Object.freeze(commands)).not.toThrow();
expect(Object.isFrozen(commands)).toBe(true);
expect(Object.keys(commands)).toEqual(['alpha', 'beta']);
});

it('moves a deleted and reassigned command to the end of key order', () => {
editor.extensionService.commands = {
alpha: () => () => true,
beta: () => () => true,
};

const service = new CommandService({ editor });
const commands = service.commands;

delete commands.alpha;
commands.alpha = () => false;

expect(Object.keys(commands)).toEqual(['beta', 'alpha']);
});

it('keeps key order when deleting a non-configurable command fails', () => {
editor.extensionService.commands = {
alpha: () => () => true,
beta: () => () => true,
};

const service = new CommandService({ editor });
const commands = service.commands;
Object.defineProperty(commands, 'alpha', { configurable: false });

expect(Reflect.deleteProperty(commands, 'alpha')).toBe(false);
expect(Object.keys(commands)).toEqual(['alpha', 'beta']);
});

it('exposes only own enumerable raw commands', () => {
const inheritedCommands = { inherited: () => () => true };
const rawCommands = Object.assign(Object.create(inheritedCommands), {
alpha: () => () => true,
});
Object.defineProperty(rawCommands, 'hidden', {
enumerable: false,
value: () => () => true,
});
editor.extensionService.commands = rawCommands;

const service = new CommandService({ editor });
const commands = service.commands;

expect(commands.inherited).toBeUndefined();
expect(commands.hidden).toBeUndefined();
expect('inherited' in commands).toBe(false);
expect('hidden' in commands).toBe(false);
expect(Object.keys(commands)).toEqual(['alpha']);
});

it('still exposes every command for enumeration/spread', () => {
editor.extensionService.commands = {
alpha: () => () => true,
beta: () => () => true,
};

const service = new CommandService({ editor });

expect(Object.keys(service.commands).sort()).toEqual(['alpha', 'beta']);
const spread = { ...service.commands };
expect(typeof spread.alpha).toBe('function');
expect(typeof spread.beta).toBe('function');
});
});
});
Loading