Skip to content

Commit

Permalink
perf: improve EditorView-hack by running it concurrently (#80)
Browse files Browse the repository at this point in the history
* feat: improve EditorView-hack by running it concurrently

* rename things

* remove experimental code

* add changesets
  • Loading branch information
TeemuKoivisto authored May 29, 2024
1 parent e0b293a commit 17ad33b
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .changeset/afraid-kids-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"extension": patch
---

fix: skip NodeViews in EditorView-hack since it doesn't work on them
5 changes: 5 additions & 0 deletions .changeset/brown-toys-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"extension": minor
---

perf: run EditorView-hack concurrently, make it a lot faster
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pnpm-lock.yaml
node_modules
build
dist
tmp
tmp
.changeset
5 changes: 3 additions & 2 deletions packages/extension/src/inject/findEditorViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { EditorView } from 'prosemirror-view'

import type { InjectState } from '../types'

import { getEditorView, sleep, tryQueryIframe } from './utils'
import { getEditorView } from './pmViewDescHack'
import { sleep, tryQueryIframe } from './utils'

const MAX_ATTEMPTS = 10

Expand Down Expand Up @@ -40,7 +41,7 @@ export async function findEditorViews(
})
)
)
const filtered = views.concat(iframeViews).filter(v => v !== undefined) as EditorView[]
const filtered = views.concat(iframeViews).filter((v): v is EditorView => v !== undefined)
if (filtered.length === 0 && attempts < MAX_ATTEMPTS) {
return findEditorViews(state, attempts + 1)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/src/inject/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { shouldRerun } from './utils'

declare global {
interface Element {
pmViewDesc?: {
pmViewDesc?: globalThis.Node['pmViewDesc'] & {
updateChildren: (view: EditorView, pos: number) => void
selectNode: () => void
deselectNode: () => void
Expand Down
100 changes: 100 additions & 0 deletions packages/extension/src/inject/pmViewDescHack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { EditorView } from 'prosemirror-view'

import { Result } from '../types/utils'

interface GetEditorViewOptions {
promises: Promise<Result<EditorView>>[]
oldFns: Map<HTMLElement, (view: EditorView, pos: number) => void>
max: number
controller: AbortController
}

function recurseElementsIntoHackPromises(el: HTMLElement, opts: GetEditorViewOptions) {
for (const child of el.children) {
if (child instanceof HTMLElement && child.pmViewDesc?.selectNode) {
if (
opts.promises.length < opts.max &&
child.pmViewDesc &&
// Skip custom NodeViews since they seem to bug out with the hack
child.pmViewDesc.constructor.name !== 'CustomNodeViewDesc'
) {
opts.promises.push(runPmViewDescHack(el, child, opts))
recurseElementsIntoHackPromises(child, opts)
}
}
}
}

async function runPmViewDescHack(
parent: HTMLElement,
child: HTMLElement,
opts: GetEditorViewOptions
): Promise<Result<EditorView>> {
if (opts.controller.signal.aborted) {
return { err: 'Finding aborted', code: 400 }
}
let oldFn = parent.pmViewDesc?.updateChildren
// If the same updateChildren is patched twice, the pmViewDesc gets broken. Ups
const alreadyPatched = opts.oldFns.has(parent)
if (!alreadyPatched && oldFn) {
opts.oldFns.set(parent, oldFn)
} else {
oldFn = opts.oldFns.get(parent)
}
const reset = () => {
if (!alreadyPatched && parent.pmViewDesc && oldFn) {
parent.pmViewDesc.updateChildren = oldFn
}
}
setTimeout(
() => {
if (child) {
child.pmViewDesc?.selectNode()
child.pmViewDesc?.deselectNode()
}
},
Math.floor(Math.random() * 100)
)

return new Promise(res => {
setTimeout(() => {
reset()
res({ err: 'Unable to trigger child.pmViewDesc.selectNode', code: 400 })
}, 1000)
opts.controller.signal.addEventListener('abort', () => {
reset()
res({ err: 'Finding aborted', code: 400 })
})
if (!alreadyPatched) {
// Monkey patch the updateChildren function only once but stay waiting for timeout incase this node's
// selection hack works
parent.pmViewDesc!.updateChildren = (view: EditorView, pos: number) => {
reset()
res({ data: view })
// @ts-ignore
return Function.prototype.bind.apply(oldFn, view, pos)
}
}
})
}

/**
* Finds elements with pmViewDesc property to try to hack their updateChildren method to retrieve EditorView
* @param el
* @returns
*/
export async function getEditorView(el: HTMLElement): Promise<EditorView | undefined> {
const opts: GetEditorViewOptions = {
promises: [],
oldFns: new Map(),
max: 50,
controller: new AbortController()
}
recurseElementsIntoHackPromises(el, opts)
const found = await Promise.any(opts.promises)
if ('data' in found) {
opts.controller.abort()
return found.data
}
return undefined
}
33 changes: 0 additions & 33 deletions packages/extension/src/inject/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { EditorView } from 'prosemirror-view'

import type { InjectMessageMap, InjectState } from '../types'

export function sleep(ms: number) {
Expand Down Expand Up @@ -30,37 +28,6 @@ export async function tryQueryIframe(iframe: HTMLIFrameElement, selector: string
}
}

export function getEditorView(el: HTMLElement): Promise<EditorView> {
const oldFn = el.pmViewDesc?.updateChildren
const childWithSelectNode = Array.from(el.children).find(
child => child.pmViewDesc && child.pmViewDesc?.selectNode !== undefined
)

setTimeout(() => {
if (childWithSelectNode !== undefined) {
childWithSelectNode.pmViewDesc?.selectNode()
childWithSelectNode.pmViewDesc?.deselectNode()
}
}, 1)

return new Promise((res, rej) => {
if (childWithSelectNode === undefined || !el.pmViewDesc) {
return rej(
'Failed to find a ProseMirror child NodeViewDesc with selectNode function (which is strange)'
)
}
el.pmViewDesc.updateChildren = (view: EditorView, pos: number) => {
if (el.pmViewDesc && oldFn) el.pmViewDesc.updateChildren = oldFn
res(view)
// @ts-ignore
return Function.prototype.bind.apply(oldFn, view, pos)
}
setTimeout(() => {
rej('Unable to trigger childWithSelectNode.pmViewDesc.selectNode')
}, 1000)
})
}

export function shouldRerun(oldState: InjectState, newState: InjectState) {
return (
!newState.disabled &&
Expand Down
6 changes: 3 additions & 3 deletions packages/extension/src/sw/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { listenToConnections } from './ports'
function register() {
return chrome.scripting.registerContentScripts([
{
id: 'inject',
id: 'prosemirror-dev-toolkit-inject',
allFrames: true,
matches: ['<all_urls>'],
js: ['inject.js'],
Expand All @@ -17,10 +17,10 @@ try {
register()
} catch (err: any) {
// When developing the extension, the old inject script might conflict with the new one
if (err.toString().includes("Duplicate script ID 'inject'")) {
if (err.toString().includes('Duplicate script ID')) {
chrome.scripting
.unregisterContentScripts({
ids: ['inject']
ids: ['prosemirror-dev-toolkit-inject']
})
.then(() => register())
}
Expand Down
8 changes: 8 additions & 0 deletions packages/extension/src/types/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type Ok<T> = {
data: T
}
export type Err = {
err: string
code: number
}
export type Result<T> = Ok<T> | Err

0 comments on commit 17ad33b

Please sign in to comment.