Skip to content

Commit

Permalink
fix: use forwarding proxy once loaded
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw committed Jan 9, 2025
1 parent 843af39 commit d0f65bc
Show file tree
Hide file tree
Showing 7 changed files with 92 additions and 67 deletions.
52 changes: 20 additions & 32 deletions packages/scripts/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,4 @@
import type { AsVoidFunctions } from './types'

type RecordingEntry =
| { type: 'get', key: string | symbol, args?: any[] }
| { type: 'apply', key: string | symbol, args: any[] }

export function createSpyProxy<T extends Record<string, any>>(instance: T = {} as T, onApply: any): T {
const stack: RecordingEntry[][] = []

let stackIdx = -1
const handler = (reuseStack = false) => ({
get(_, prop, receiver) {
if (!reuseStack) {
stackIdx++ // root get triggers a new stack
stack[stackIdx] = []
}
stack[stackIdx].push({ type: 'get', key: prop })
const v = Reflect.get(_, prop, receiver)
if (v) {
return new Proxy(v, handler(true))
}
},
apply(_, __, args) {
stack[stackIdx].push({ type: 'apply', key: '', args })
onApply(stack, args)
// @ts-expect-error untyped
return Reflect.apply(_, __, args)
},
} as ProxyHandler<T>)

return new Proxy(instance, handler())
}
import type { AsVoidFunctions, RecordingEntry } from './types'

export function createNoopedRecordingProxy<T extends Record<string, any>>(instance: T = {} as T): { proxy: AsVoidFunctions<T>, stack: RecordingEntry[][] } {
const stack: RecordingEntry[][] = []
Expand Down Expand Up @@ -61,6 +30,25 @@ export function createNoopedRecordingProxy<T extends Record<string, any>>(instan
}
}

export function createForwardingProxy<T extends Record<string, any>>(target: T): AsVoidFunctions<T> {
const handler: ProxyHandler<T> = {
get(_, prop, receiver) {
const v = Reflect.get(_, prop, receiver)
if (typeof v === 'object') {
return new Proxy(v, handler)
}
return v
},
apply(_, __, args) {
// does not return the apply output for consistency
// @ts-expect-error untyped
Reflect.apply(_, __, args)
return undefined
},
}
return new Proxy(target, handler) as AsVoidFunctions<T>
}

export function replayProxyRecordings<T extends object>(target: T, stack: RecordingEntry[][]) {
stack.forEach((recordings) => {
let context: any = target
Expand Down
4 changes: 4 additions & 0 deletions packages/scripts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export interface ScriptInstance<T extends BaseScriptApi> {
}
}

export type RecordingEntry =
| { type: 'get', key: string | symbol, args?: any[], value?: any }
| { type: 'apply', key: string | symbol, args: any[] }

export interface UseScriptOptions<T extends BaseScriptApi = Record<string, any>> extends HeadEntryOptions {
/**
* Resolve the script instance from the window.
Expand Down
4 changes: 3 additions & 1 deletion packages/scripts/src/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
} from './types'
import { hashCode, ScriptNetworkEvents } from '@unhead/shared'
import { useUnhead } from 'unhead'
import { createNoopedRecordingProxy, replayProxyRecordings } from './proxy'
import { createForwardingProxy, createNoopedRecordingProxy, replayProxyRecordings } from './proxy'

export function resolveScriptKey(input: UseScriptResolvedInput) {
return input.key || hashCode(input.src || (typeof input.innerHTML === 'string' ? input.innerHTML : ''))
Expand Down Expand Up @@ -229,6 +229,8 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
script.proxy = proxy
script.onLoaded((instance) => {
replayProxyRecordings(instance, stack)
// just forward everything with the same behavior
script.proxy = createForwardingProxy(instance)
})
}
// need to make sure it's not already registered
Expand Down
31 changes: 31 additions & 0 deletions packages/scripts/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { RecordingEntry } from './types'

export function createSpyProxy<T extends Record<string, any> | any[]>(target: T, onApply: (stack: RecordingEntry[][]) => void): T {
const stack: RecordingEntry[][] = []

let stackIdx = -1
const handler = (reuseStack = false) => ({
get(_, prop, receiver) {
if (!reuseStack) {
stackIdx++ // root get triggers a new stack
stack[stackIdx] = []
}
const v = Reflect.get(_, prop, receiver)
if (typeof v === 'object' || typeof v === 'function') {
stack[stackIdx].push({ type: 'get', key: prop })
// @ts-expect-error untyped
return new Proxy(v, handler(true))
}
stack[stackIdx].push({ type: 'get', key: prop, value: v })
return v
},
apply(_, __, args) {
stack[stackIdx].push({ type: 'apply', key: '', args })
onApply(stack)
// @ts-expect-error untyped
return Reflect.apply(_, __, args)
},
} as ProxyHandler<T>)

return new Proxy(target, handler())
}
2 changes: 1 addition & 1 deletion packages/scripts/src/vue-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function registerVueScopeHandlers<T extends Record<symbol | string, any> = Recor
})
}

export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>, U = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptInput
const options = _options || {} as UseScriptOptions<T>
const head = options?.head || injectHead()
Expand Down
2 changes: 1 addition & 1 deletion packages/scripts/src/vue/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function registerVueScopeHandlers<T extends Record<symbol | string, any> = Recor
})
}

export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>, U = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(_input: UseScriptInput, _options?: UseScriptOptions<T>): UseScriptContext<UseFunctionType<UseScriptOptions<T>, T>> {
const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptInput
const options = _options || {} as UseScriptOptions<T>
const head = options?.head || injectHead()
Expand Down
64 changes: 32 additions & 32 deletions packages/scripts/test/unit/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { AsVoidFunctions } from '../../src'
import { describe, expect, expectTypeOf, it } from 'vitest'
import { createServerHeadWithContext } from '../../../../test/util'
import { createNoopedRecordingProxy, createSpyProxy, replayProxyRecordings } from '../../src/proxy'
import { createForwardingProxy } from '../../src'
import { createNoopedRecordingProxy, replayProxyRecordings } from '../../src/proxy'
import { useScript } from '../../src/useScript'
import { createSpyProxy } from '../../src/utils'

interface Api {
_paq: any[]
Expand All @@ -24,25 +26,39 @@ describe('proxy chain', () => {
expectTypeOf(proxy.proxy.say).parameter(0).toBeString()
expectTypeOf(proxy.proxy.foo.bar.fn).toBeFunction()
})
it('basic queue', async () => {
const script: { instance: (null | Api) } = { instance: null }
it('e2e', async () => {
// do recording
const { proxy, stack } = createNoopedRecordingProxy<Api>()
proxy._paq.push(['test'])
proxy.say('hello world')
const script = { proxy, instance: null }
script.proxy._paq.push(['test'])
script.proxy.say('hello world')
expect(stack.length).toBe(2)

script.instance = {
_paq: [],
say(s: string) {
let called
const w: any = {
_paq: createSpyProxy([], () => {
called = true
}),
say: (s: string) => {
console.log(s)
return s
},
}
// did load
script.instance = {
_paq: w._paq,
say: w.say,
}
const log = console.log
// replay recording
const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined)
const consoleMock = vi.spyOn(console, 'log').mockImplementation((...args) => {
log('mocked', ...args)
})
replayProxyRecordings(script.instance, stack)
// @ts-expect-error untyped
script.proxy = createForwardingProxy(script.instance)
expect(consoleMock).toHaveBeenCalledWith('hello world')
script.proxy.say('proxy updated!')
expect(consoleMock).toHaveBeenCalledWith('proxy updated!')
expect(script.instance).toMatchInlineSnapshot(`
{
"_paq": [
Expand All @@ -53,14 +69,15 @@ describe('proxy chain', () => {
"say": [Function],
}
`)
script.proxy._paq.push(['test'])
consoleMock.mockReset()
expect(called).toBe(true)
})
it('spy', () => {
const w = {}
const w: any = {}
w._paq = []
const stack = []
// eslint-disable-next-line unused-imports/no-unused-vars
w._paq = createSpyProxy(w._paq, (s, arg) => {
const stack: any[] = []
w._paq = createSpyProxy(w._paq, (s) => {
stack.push(s)
})
w._paq.push(['test'])
Expand All @@ -86,30 +103,13 @@ describe('proxy chain', () => {
{
"key": "length",
"type": "get",
"value": 0,
},
],
],
]
`)
})
it('should keep array properties unchanged', () => {
type Result = AsVoidFunctions<Api>
expectTypeOf<Result['arrayProp']>().toEqualTypeOf<string[]>()
})

it('should convert function properties to void functions', () => {
type Result = AsVoidFunctions<Api>
expectTypeOf<Result['funcProp']>().toBeFunction()
expectTypeOf<Result['funcProp']>().parameters.toEqualTypeOf<[number]>()
expectTypeOf<Result['funcProp']>().returns.toBeVoid()
})

it('should recursively convert nested function properties to void functions', () => {
type Result = AsVoidFunctions<Api>
expectTypeOf<Result['nestedProp']['innerFunc']>().toBeFunction()
expectTypeOf<Result['nestedProp']['innerFunc']>().parameters.toEqualTypeOf<[boolean]>()
expectTypeOf<Result['nestedProp']['innerFunc']>().returns.toBeVoid()
})
it('use() provided', () => {
const head = createServerHeadWithContext()
const instance = useScript({
Expand Down

0 comments on commit d0f65bc

Please sign in to comment.