Skip to content

Commit

Permalink
Execution Count Limiter (#6353)
Browse files Browse the repository at this point in the history
- Added `limitExecutionCount`, which can wrap around a function and
prevent repeated execution with an exception being thrown.
- Wrapped `runDomSampler` with `limitExecutionCount`.
  • Loading branch information
seanparsons authored Sep 19, 2024
1 parent 6cb11b2 commit e8c7341
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 8 deletions.
23 changes: 22 additions & 1 deletion editor/src/components/canvas/dom-sampler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ import {
getAttributesComingFromStyleSheets,
} from './dom-walker'
import type { UiJsxCanvasContextData } from './ui-jsx-canvas'
import type { LimitExecutionCountReset } from '../../core/shared/execution-count'
import { limitExecutionCount } from '../../core/shared/execution-count'
import { IS_TEST_ENVIRONMENT } from '../../common/env-vars'

export function runDomSampler(options: {
export function runDomSamplerUnchecked(options: {
elementsToFocusOn: ElementsToRerender
domWalkerAdditionalElementsToFocusOn: Array<ElementPath>
scale: number
Expand Down Expand Up @@ -126,6 +128,25 @@ export function runDomSampler(options: {
return result
}

let domSamplerExecutionLimitResets: Array<LimitExecutionCountReset> = []

export function resetDomSamplerExecutionCounts() {
for (const reset of domSamplerExecutionLimitResets) {
reset()
}
}

const wrappedRunDomSamplerRegular = limitExecutionCount(
{ maximumExecutionCount: 1, addToResetArray: domSamplerExecutionLimitResets },
runDomSamplerUnchecked,
)
const wrappedRunDomSamplerGroups = limitExecutionCount(
{ maximumExecutionCount: 1, addToResetArray: domSamplerExecutionLimitResets },
runDomSamplerUnchecked,
)
export const runDomSamplerRegular = wrappedRunDomSamplerRegular.wrappedFunction
export const runDomSamplerGroups = wrappedRunDomSamplerGroups.wrappedFunction

function collectMetadataForPaths({
canvasRootContainer,
pathsToCollect,
Expand Down
4 changes: 2 additions & 2 deletions editor/src/components/canvas/editor-dispatch-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { updateMetadataInEditorState } from '../editor/actions/action-creators'
import type { DispatchResult } from '../editor/store/dispatch'
import { editorDispatchActionRunner } from '../editor/store/dispatch'
import type { EditorStoreFull } from '../editor/store/editor-state'
import { runDomSampler } from './dom-sampler'
import { runDomSamplerRegular } from './dom-sampler'
import { resubscribeObservers } from './dom-walker'
import { ElementsToRerenderGLOBAL, type UiJsxCanvasContextData } from './ui-jsx-canvas'

Expand Down Expand Up @@ -32,7 +32,7 @@ export function runDomSamplerAndSaveResults(
},
spyCollector: UiJsxCanvasContextData,
) {
const metadataResult = runDomSampler({
const metadataResult = runDomSamplerRegular({
elementsToFocusOn: ElementsToRerenderGLOBAL.current,
domWalkerAdditionalElementsToFocusOn:
storedState.patchedEditor.canvas.domWalkerAdditionalElementsToUpdate,
Expand Down
11 changes: 8 additions & 3 deletions editor/src/components/canvas/ui-jsx.test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ import {
import { uniqBy } from '../../core/shared/array-utils'
import { InitialOnlineState } from '../editor/online-status'
import { RadixComponentsPortalId } from '../../uuiui/radix-components'
import { runDomSampler } from './dom-sampler'
import {
resetDomSamplerExecutionCounts,
runDomSamplerGroups,
runDomSamplerRegular,
} from './dom-sampler'
import {
ElementInstanceMetadataKeepDeepEquality,
ElementInstanceMetadataMapKeepDeepEquality,
Expand Down Expand Up @@ -354,6 +358,7 @@ export async function renderTestEditorWithModel(
waitForDispatchEntireUpdate = false,
innerStrategiesToUse: Array<MetaCanvasStrategy> = strategiesToUse,
) => {
resetDomSamplerExecutionCounts()
recordedActions.push(...actions)
const originalEditorState = workingEditorState
const result = editorDispatchActionRunner(
Expand Down Expand Up @@ -430,7 +435,7 @@ export async function renderTestEditorWithModel(
{
resubscribeObservers(domWalkerMutableState)

const metadataResult = runDomSampler({
const metadataResult = runDomSamplerRegular({
elementsToFocusOn: workingEditorState.patchedEditor.canvas.elementsToRerender,
domWalkerAdditionalElementsToFocusOn:
workingEditorState.patchedEditor.canvas.domWalkerAdditionalElementsToUpdate,
Expand Down Expand Up @@ -502,7 +507,7 @@ export async function renderTestEditorWithModel(
{
resubscribeObservers(domWalkerMutableState)

const metadataResult = runDomSampler({
const metadataResult = runDomSamplerGroups({
elementsToFocusOn: workingEditorState.patchedEditor.canvas.elementsToRerender,
domWalkerAdditionalElementsToFocusOn:
workingEditorState.patchedEditor.canvas.domWalkerAdditionalElementsToUpdate,
Expand Down
90 changes: 90 additions & 0 deletions editor/src/core/shared/execution-count.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { LimitExecutionCountReset } from './execution-count'
import { limitExecutionCount } from './execution-count'

describe('limitExecutionCount', () => {
it('the wrapped function should retain its type', () => {
function putNumberOnString(str: string, num: number): string {
return `${str}-${num}`
}
const wrappedFunction = limitExecutionCount(
{ maximumExecutionCount: 1 },
putNumberOnString,
).wrappedFunction
expect(wrappedFunction('hello', 1)).toBe('hello-1')
})
it('should let a function be called the maximum number of times', () => {
const fn = jest.fn()
const { wrappedFunction } = limitExecutionCount({ maximumExecutionCount: 3 }, fn)
wrappedFunction()
wrappedFunction()
wrappedFunction()
expect(fn).toHaveBeenCalledTimes(3)
})
it('should let a function be called the maximum number of times, then reset and called the maximum number of times again', () => {
const fn = jest.fn()
const { wrappedFunction, resetCount } = limitExecutionCount({ maximumExecutionCount: 3 }, fn)
wrappedFunction()
wrappedFunction()
wrappedFunction()
expect(fn).toHaveBeenCalledTimes(3)
resetCount()
wrappedFunction()
wrappedFunction()
wrappedFunction()
expect(fn).toHaveBeenCalledTimes(6)
})
it('should let a function be called the maximum number of times, then reset via the reset array and called the maximum number of times again', () => {
const fn = jest.fn()
const resetArray: Array<LimitExecutionCountReset> = []
const { wrappedFunction } = limitExecutionCount(
{ maximumExecutionCount: 3, addToResetArray: resetArray },
fn,
)
wrappedFunction()
wrappedFunction()
wrappedFunction()
expect(fn).toHaveBeenCalledTimes(3)
resetArray.forEach((reset) => reset())
wrappedFunction()
wrappedFunction()
wrappedFunction()
expect(fn).toHaveBeenCalledTimes(6)
})
it('should throw an exception if the function is called more than the maximum number of times', () => {
const fn = jest.fn()
const { wrappedFunction } = limitExecutionCount({ maximumExecutionCount: 2 }, fn)
wrappedFunction()
wrappedFunction()
expect(() => {
wrappedFunction()
}).toThrowErrorMatchingInlineSnapshot(`"Function exceeded maximum execution count of 2."`)
})
it('should throw an exception if the function is called more than the maximum number of times, after a reset', () => {
const fn = jest.fn()
const { wrappedFunction, resetCount } = limitExecutionCount({ maximumExecutionCount: 2 }, fn)
wrappedFunction()
wrappedFunction()
resetCount()
wrappedFunction()
wrappedFunction()
expect(() => {
wrappedFunction()
}).toThrowErrorMatchingInlineSnapshot(`"Function exceeded maximum execution count of 2."`)
})
it('should throw an exception if the function is called more than the maximum number of times, after a reset via the reset array', () => {
const fn = jest.fn()
const resetArray: Array<LimitExecutionCountReset> = []
const { wrappedFunction } = limitExecutionCount(
{ maximumExecutionCount: 2, addToResetArray: resetArray },
fn,
)
wrappedFunction()
wrappedFunction()
resetArray.forEach((reset) => reset())
wrappedFunction()
wrappedFunction()
expect(() => {
wrappedFunction()
}).toThrowErrorMatchingInlineSnapshot(`"Function exceeded maximum execution count of 2."`)
})
})
62 changes: 62 additions & 0 deletions editor/src/core/shared/execution-count.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type LimitExecutionCountReset = () => void

export interface LimitExecutionCountOptions {
maximumExecutionCount: number
addToResetArray: Array<LimitExecutionCountReset> | null
}

export interface LimitExecutionCountState {
executionCount: number
}

export interface LimitExecutionCountResult<Args extends ReadonlyArray<unknown>, Result> {
wrappedFunction: (...args: Args) => Result
resetCount: LimitExecutionCountReset
}

const defaultOptions: LimitExecutionCountOptions = {
maximumExecutionCount: 1,
addToResetArray: null,
}

export function limitExecutionCount<Args extends ReadonlyArray<unknown>, Result>(
options: Partial<LimitExecutionCountOptions>,
fn: (...args: Args) => Result,
): LimitExecutionCountResult<Args, Result> {
// Setup the default and specified options and state.
const fullySpecifiedOptions: LimitExecutionCountOptions = {
...defaultOptions,
...options,
}
let state: LimitExecutionCountState = {
executionCount: 0,
}

// Wrap the function supplied by the user.
const wrappedFunction = (...args: Args): Result => {
if (state.executionCount >= fullySpecifiedOptions.maximumExecutionCount) {
throw new Error(
`Function exceeded maximum execution count of ${fullySpecifiedOptions.maximumExecutionCount}.`,
)
}

state.executionCount += 1
return fn(...args)
}

// Create the function for resetting the count.
const resetCount = (): void => {
state.executionCount = 0
}

// Add into the reset array.
if (options.addToResetArray != null) {
options.addToResetArray.push(resetCount)
}

// Build the result.
return {
wrappedFunction: wrappedFunction,
resetCount: resetCount,
}
}
8 changes: 6 additions & 2 deletions editor/src/templates/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ import {
traverseReadOnlyArray,
} from '../core/shared/optics/optic-creators'
import { keysEqualityExhaustive, shallowEqual } from '../core/shared/equality-utils'
import { runDomSampler } from '../components/canvas/dom-sampler'
import {
resetDomSamplerExecutionCounts,
runDomSamplerGroups,
} from '../components/canvas/dom-sampler'
import { omitWithPredicate } from '../core/shared/object-utils'

if (PROBABLY_ELECTRON) {
Expand Down Expand Up @@ -431,6 +434,7 @@ export class Editor {
): {
entireUpdateFinished: Promise<any>
} => {
resetDomSamplerExecutionCounts()
const Measure = createPerformanceMeasure()
Measure.logActions(dispatchedActions)

Expand Down Expand Up @@ -543,7 +547,7 @@ export class Editor {

// re-run the dom-sampler
Measure.taskTime(`Dom walker re-run because of groups ${updateId}`, () => {
const metadataResult = runDomSampler({
const metadataResult = runDomSamplerGroups({
elementsToFocusOn: ElementsToRerenderGLOBAL.current,
domWalkerAdditionalElementsToFocusOn:
this.storedState.patchedEditor.canvas.domWalkerAdditionalElementsToUpdate,
Expand Down

0 comments on commit e8c7341

Please sign in to comment.