presence-related fixes (#1361)
This fixes a bug where creating a page would fail if there were multiple pages with the same index. This also changes the store to use a throttled version of requestAnimationFrame. This should be good for relieving backpressure in situations where the store is updated many times in quick succession. It also makes testing a lot easier since it has the mocking logic built in. ### Change Type - [x] `patch` — Bug Fix ### Release Notes - Fix a bug where creating a page could throw an error in some multiplayer contexts.
This commit is contained in:
parent
9ccd0f480f
commit
d76446646c
7 changed files with 128 additions and 15 deletions
|
@ -5050,7 +5050,10 @@ export class App extends EventEmitter<TLEventMap> {
|
|||
const newPage = TLPage.create({
|
||||
id,
|
||||
name: title,
|
||||
index: bottomIndex ? getIndexBetween(topIndex, bottomIndex) : getIndexAbove(topIndex),
|
||||
index:
|
||||
bottomIndex && topIndex !== bottomIndex
|
||||
? getIndexBetween(topIndex, bottomIndex)
|
||||
: getIndexAbove(topIndex),
|
||||
})
|
||||
|
||||
const newCamera = TLCamera.create({})
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { TLPage } from '@tldraw/tlschema'
|
||||
import { MAX_PAGES } from '../../constants'
|
||||
import { TestApp } from '../TestApp'
|
||||
|
||||
|
@ -28,3 +29,30 @@ it("Doesn't create a page if max pages is reached", () => {
|
|||
}
|
||||
expect(app.pages.length).toBe(MAX_PAGES)
|
||||
})
|
||||
|
||||
it('[regression] does not die if every page has the same index', () => {
|
||||
expect(app.pages.length).toBe(1)
|
||||
const page = app.pages[0]
|
||||
app.store.put([
|
||||
{
|
||||
...page,
|
||||
id: TLPage.createCustomId('2'),
|
||||
name: 'a',
|
||||
},
|
||||
{
|
||||
...page,
|
||||
id: TLPage.createCustomId('3'),
|
||||
name: 'b',
|
||||
},
|
||||
{
|
||||
...page,
|
||||
id: TLPage.createCustomId('4'),
|
||||
name: 'c',
|
||||
},
|
||||
])
|
||||
|
||||
expect(app.pages.every((p) => p.index === page.index)).toBe(true)
|
||||
|
||||
app.createPage('My Special Test Page')
|
||||
expect(app.pages.some((p) => p.name === 'My Special Test Page')).toBe(true)
|
||||
})
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { throttledRaf } from '@tldraw/utils'
|
||||
import { atom, Atom, computed, Computed, Reactor, reactor, transact } from 'signia'
|
||||
import { BaseRecord, ID } from './BaseRecord'
|
||||
import { Cache } from './Cache'
|
||||
|
@ -172,7 +173,7 @@ export class Store<R extends BaseRecord = BaseRecord, Props = unknown> {
|
|||
// If we have accumulated history, flush it and update listeners
|
||||
this._flushHistory()
|
||||
},
|
||||
{ scheduleEffect: (cb) => requestAnimationFrame(cb) }
|
||||
{ scheduleEffect: (cb) => throttledRaf(cb) }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -469,26 +469,33 @@ describe('Store', () => {
|
|||
})
|
||||
|
||||
it('flushes history before attaching listeners', async () => {
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
const firstListener = jest.fn()
|
||||
store.listen(firstListener)
|
||||
expect(firstListener).toHaveBeenCalledTimes(0)
|
||||
try {
|
||||
// @ts-expect-error
|
||||
globalThis.__FORCE_RAF_IN_TESTS__ = true
|
||||
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') })])
|
||||
const firstListener = jest.fn()
|
||||
store.listen(firstListener)
|
||||
expect(firstListener).toHaveBeenCalledTimes(0)
|
||||
|
||||
store.put([Author.create({ name: 'Chips McCoy', id: Author.createCustomId('chips') })])
|
||||
store.put([Author.create({ name: 'Chips McCoy', id: Author.createCustomId('chips') })])
|
||||
|
||||
expect(firstListener).toHaveBeenCalledTimes(0)
|
||||
expect(firstListener).toHaveBeenCalledTimes(0)
|
||||
|
||||
const secondListener = jest.fn()
|
||||
const secondListener = jest.fn()
|
||||
|
||||
store.listen(secondListener)
|
||||
store.listen(secondListener)
|
||||
|
||||
expect(firstListener).toHaveBeenCalledTimes(1)
|
||||
expect(secondListener).toHaveBeenCalledTimes(0)
|
||||
expect(firstListener).toHaveBeenCalledTimes(1)
|
||||
expect(secondListener).toHaveBeenCalledTimes(0)
|
||||
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
||||
|
||||
expect(firstListener).toHaveBeenCalledTimes(1)
|
||||
expect(secondListener).toHaveBeenCalledTimes(0)
|
||||
expect(firstListener).toHaveBeenCalledTimes(1)
|
||||
expect(secondListener).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
// @ts-expect-error
|
||||
globalThis.__FORCE_RAF_IN_TESTS__ = false
|
||||
}
|
||||
})
|
||||
|
||||
it('does not overwrite default properties with undefined', () => {
|
||||
|
|
|
@ -122,6 +122,9 @@ export function promiseWithResolve<T>(): Promise<T> & {
|
|||
reject: (reason?: any) => void;
|
||||
};
|
||||
|
||||
// @internal
|
||||
export function rafThrottle(fn: () => void): () => void;
|
||||
|
||||
// @public (undocumented)
|
||||
export type Result<T, E> = ErrorResult<E> | OkResult<T>;
|
||||
|
||||
|
@ -144,6 +147,9 @@ export { structuredClone_2 as structuredClone }
|
|||
// @public
|
||||
export function throttle<T extends (...args: any) => any>(func: T, limit: number): (...args: Parameters<T>) => ReturnType<T>;
|
||||
|
||||
// @internal
|
||||
export function throttledRaf(fn: () => void): void;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
||||
|
|
|
@ -23,4 +23,5 @@ export {
|
|||
objectMapKeys,
|
||||
objectMapValues,
|
||||
} from './lib/object'
|
||||
export { rafThrottle, throttledRaf } from './lib/raf'
|
||||
export { isDefined, isNonNull, isNonNullish, structuredClone } from './lib/value'
|
||||
|
|
67
packages/utils/src/lib/raf.ts
Normal file
67
packages/utils/src/lib/raf.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
const isTest = () =>
|
||||
typeof process !== 'undefined' &&
|
||||
process.env.NODE_ENV === 'test' &&
|
||||
// @ts-expect-error
|
||||
!globalThis.__FORCE_RAF_IN_TESTS__
|
||||
|
||||
const rafQueue: Array<() => void> = []
|
||||
|
||||
const tick = () => {
|
||||
const queue = rafQueue.splice(0, rafQueue.length)
|
||||
for (const fn of queue) {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
let frame: number | undefined
|
||||
|
||||
function raf() {
|
||||
if (frame) {
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
tick()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a throttled version of the function that will only be called max once per frame.
|
||||
* @param fn - the fun to return a throttled version of
|
||||
* @returns
|
||||
* @internal
|
||||
*/
|
||||
export function rafThrottle(fn: () => void) {
|
||||
if (isTest()) {
|
||||
return fn
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafQueue.includes(fn)) {
|
||||
return
|
||||
}
|
||||
rafQueue.push(fn)
|
||||
raf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the function on the next frame.
|
||||
* If the same fn is passed again before the next frame, it will still be called only once.
|
||||
* @param fn - the fun to call on the next animation frame
|
||||
* @returns
|
||||
* @internal
|
||||
*/
|
||||
export function throttledRaf(fn: () => void) {
|
||||
if (isTest()) {
|
||||
return fn()
|
||||
}
|
||||
|
||||
if (rafQueue.includes(fn)) {
|
||||
return
|
||||
}
|
||||
|
||||
rafQueue.push(fn)
|
||||
raf()
|
||||
}
|
Loading…
Reference in a new issue