diff --git a/src/components/DocumentView/CodeBlock/highlight.test.ts b/src/components/DocumentView/CodeBlock/highlight.test.ts index 2aef96b679..faed7f48dc 100644 --- a/src/components/DocumentView/CodeBlock/highlight.test.ts +++ b/src/components/DocumentView/CodeBlock/highlight.test.ts @@ -35,6 +35,33 @@ it('should parse plain code', async () => { ]); }); +it('should parse different code in parallel', async () => { + await Promise.all( + ['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) => + highlight({ + object: 'block', + type: 'code', + data: { + syntax: syntax, + }, + nodes: [ + { + object: 'block', + type: 'code-line', + data: {}, + nodes: [ + { + object: 'text', + leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }], + }, + ], + }, + ], + }), + ), + ); +}); + it('should parse a multilines plain code', async () => { const tokens = await highlight({ object: 'block', @@ -561,45 +588,30 @@ it('should support multiple code tokens in an annotation', async () => { type: 'shiki', token: { content: 'const', - color: '#000007', - start: 0, - end: 5, }, }, { type: 'shiki', token: { content: ' ', - color: '#000001', - start: 5, - end: 6, }, }, { type: 'shiki', token: { content: 'a', - color: '#000004', - start: 6, - end: 7, }, }, { type: 'shiki', token: { content: ' ', - color: '#000001', - start: 7, - end: 8, }, }, { type: 'shiki', token: { content: '=', - color: '#000007', - start: 8, - end: 9, }, }, { @@ -613,27 +625,18 @@ it('should support multiple code tokens in an annotation', async () => { type: 'shiki', token: { content: 'hello', - color: '#000004', - start: 9, - end: 14, }, }, { type: 'shiki', token: { content: '.world', - color: '#000009', - start: 14, - end: 20, }, }, { type: 'shiki', token: { content: '(', - color: '#000001', - start: 20, - end: 21, }, }, ], @@ -642,9 +645,6 @@ it('should support multiple code tokens in an annotation', async () => { type: 'shiki', token: { content: ');', - color: '#000001', - start: 21, - end: 23, }, }, ], diff --git a/src/components/DocumentView/CodeBlock/highlight.ts b/src/components/DocumentView/CodeBlock/highlight.ts index db9adcdab2..7525d4f27b 100644 --- a/src/components/DocumentView/CodeBlock/highlight.ts +++ b/src/components/DocumentView/CodeBlock/highlight.ts @@ -9,7 +9,7 @@ import { // @ts-ignore - onigWasm is a Wasm module import onigWasm from 'shiki/onig.wasm?module'; -import { singleton, singletonMap } from '@/lib/async'; +import { asyncMutexFunction, singleton } from '@/lib/async'; import { getNodeText } from '@/lib/document'; import { trace } from '@/lib/tracing'; @@ -314,10 +314,13 @@ const loadHighlighter = singleton(async () => { }); }); -const loadHighlighterLanguage = singletonMap(async (lang: keyof typeof bundledLanguages) => { - const highlighter = await loadHighlighter(); - await trace( - `highlighting.loadLanguage(${lang})`, - async () => await highlighter.loadLanguage(lang), - ); -}); +const loadLanguagesMutex = asyncMutexFunction(); +async function loadHighlighterLanguage(lang: keyof typeof bundledLanguages) { + await loadLanguagesMutex.runBlocking(async () => { + const highlighter = await loadHighlighter(); + await trace( + `highlighting.loadLanguage(${lang})`, + async () => await highlighter.loadLanguage(lang), + ); + }); +} diff --git a/src/lib/async.test.ts b/src/lib/async.test.ts index ed7c1cd132..5891f69f94 100644 --- a/src/lib/async.test.ts +++ b/src/lib/async.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { race } from './async'; +import { asyncMutexFunction, race } from './async'; import { flushWaitUntil } from './waitUntil'; describe('race', () => { @@ -380,3 +380,88 @@ describe('race', () => { }); }); }); + +describe('asyncMutexFunction', () => { + describe('self', () => { + it('should run only once', async () => { + let running = 0; + + const fn = async () => { + running += 1; + + await new Promise((resolve) => setTimeout(resolve, 10)); + }; + + const mutexFn = asyncMutexFunction(); + + await Promise.all([ + mutexFn(fn), + mutexFn(fn), + mutexFn(fn), + mutexFn(fn), + mutexFn(fn), + mutexFn(fn), + ]); + + expect(running).toBe(1); + }); + }); + + describe('runBlocking', () => { + it('should run only one function at a time', async () => { + let running = 0; + let maxRunning = 0; + + const fn = async () => { + running += 1; + maxRunning = Math.max(running, maxRunning); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + running -= 1; + }; + + const mutexFn = asyncMutexFunction(); + + await Promise.all([ + mutexFn.runBlocking(fn), + mutexFn.runBlocking(fn), + mutexFn.runBlocking(fn), + mutexFn.runBlocking(fn), + mutexFn.runBlocking(fn), + mutexFn.runBlocking(fn), + ]); + + expect(maxRunning).toBe(1); + }); + }); + + describe('runAfter', () => { + it('should run only one function at a time', async () => { + let running = 0; + let maxRunning = 0; + + const fn = async () => { + running += 1; + maxRunning = Math.max(running, maxRunning); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + running -= 1; + }; + + const mutexFn = asyncMutexFunction(); + + await Promise.all([ + mutexFn.runAfter(fn), + mutexFn.runAfter(fn), + mutexFn.runAfter(fn), + mutexFn.runAfter(fn), + mutexFn.runAfter(fn), + mutexFn.runAfter(fn), + ]); + + expect(maxRunning).toBe(1); + }); + }); +}); diff --git a/src/lib/async.ts b/src/lib/async.ts index 26b875f289..e99cb26f7d 100644 --- a/src/lib/async.ts +++ b/src/lib/async.ts @@ -361,3 +361,112 @@ export function batch( }); }; } + +type MutexOperationOptions = { + /** + * If true, fail the operation if a pending operation on the mutex fails. + * Defaults to true. + */ + failOnMutexError?: boolean; +}; + +export type AsyncMutexFunction = ((fn: () => Promise) => Promise) & { + /** + * Wait for a pending operation to complete. + */ + wait: () => Promise; + + /** + * Execute a function after the previous operation completes. + */ + runAfter: (fn: () => Promise, options?: MutexOperationOptions) => Promise; + + /** + * Execute a function that blocks the mutex, but does not influence the return value of the mutex. + */ + runBlocking: ( + fn: () => Promise, + options?: MutexOperationOptions, + ) => Promise; +}; + +/** + * Creates a function that will only call the given function once at a time. + */ +export function asyncMutexFunction(): AsyncMutexFunction { + let pending: + | undefined + | { kind: 'value'; promise: Promise } + | { kind: 'blocking'; promise: Promise }; + + const mutex: AsyncMutexFunction = async (fn) => { + if (pending?.kind === 'value') { + return pending.promise; + } + + while (pending) { + await pending.promise; + } + + const promise = fn(); + pending = { kind: 'value', promise }; + try { + const result = await promise; + return result; + } finally { + pending = undefined; + } + }; + + mutex.wait = async () => { + return pending?.promise; + }; + + mutex.runBlocking = async (fn, options) => { + const failOnMutexError = options?.failOnMutexError ?? true; + + while (pending) { + try { + await pending.promise; + } catch (err) { + if (failOnMutexError) { + throw err; + } + } + } + + const promise = fn(); + pending = { kind: 'blocking', promise }; + try { + const result = await promise; + return result; + } finally { + pending = undefined; + } + }; + + mutex.runAfter = async (fn, options) => { + const failOnMutexError = options?.failOnMutexError ?? true; + + while (pending) { + try { + await pending.promise; + } catch (err) { + if (failOnMutexError) { + throw err; + } + } + } + + const promise = fn(); + pending = { kind: 'value', promise }; + try { + const result = await pending.promise; + return result; + } finally { + pending = undefined; + } + }; + + return mutex; +}