mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 08:04:41 +00:00
fix(session-ui): cancel abandoned completed Markdown parse jobs (#46764)
This commit is contained in:
parent
311e32da93
commit
1bd1f72bcf
19 changed files with 755 additions and 28 deletions
3
bun.lock
3
bun.lock
|
|
@ -730,6 +730,7 @@
|
|||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/simulation": {
|
||||
|
|
@ -6255,6 +6256,8 @@
|
|||
|
||||
"@opencode-ai/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode-ai/session-ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
|
||||
|
||||
"@opencode-ai/stats-app/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode-ai/storybook/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
|
||||
|
|
|
|||
44
packages/app/e2e/performance/markdown/README.md
Normal file
44
packages/app/e2e/performance/markdown/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Timeline Preload Lifetime
|
||||
|
||||
This manual benchmark uses the production app, restored session tabs, the real
|
||||
`MessageTimeline` preload, and the real Markdown worker. Only API data and result
|
||||
delivery timing are fixture-owned. It does not connect to a running OpenCode
|
||||
service or send prompts.
|
||||
|
||||
From `packages/app`, set absolute `MARKDOWN_APP_BUILD_DIR` and
|
||||
`MARKDOWN_RESULTS_DIR` artifact paths, then run:
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
# Copy dist into MARKDOWN_APP_BUILD_DIR before editing production source.
|
||||
bun --bun x playwright test --config e2e/performance/markdown/playwright.config.ts --repeat-each 20
|
||||
```
|
||||
|
||||
Each isolated sample restores two sessions with one user message and one completed
|
||||
assistant text part each. The cold target has a realistic recovery review with
|
||||
either two TypeScript fences (typical) or 36 fences (large). The source has a short
|
||||
completed answer. Target data is prefetched before selection, but its Markdown is
|
||||
not parsed until the target is selected.
|
||||
|
||||
The app's service-worker generator reads `dist`, so use the normal build output
|
||||
and freeze a copy, rather than overriding Vite's build output directory.
|
||||
|
||||
The real worker result is held after admission. The test selects the original
|
||||
session again and releases the held result only after the abandoned timeline row
|
||||
detaches and the selected answer reports production Markdown readiness. This
|
||||
exercises both the timeline preload and the nested Markdown consumer, including
|
||||
the case where either one would otherwise keep a shared parse alive.
|
||||
|
||||
Destination readiness and post-disposal result settlement are separate metrics.
|
||||
The latter is a MessageChannel task after the result's promise microtasks drain.
|
||||
The DOMParser probe counts actual DOMPurify input containing the abandoned answer
|
||||
after disposal, in characters. CDP reports renderer task/script time and JS heap.
|
||||
These are not worker CPU, Electron process RAM, or ungated tab-switch measurements.
|
||||
In particular, this gate releases the result after destination readiness and must
|
||||
not be used to claim a destination-readiness gain from skipping sanitization.
|
||||
|
||||
`MARKDOWN_ASSERT_DISPOSAL=1` enables the no-obsolete-sanitization assertion. Use
|
||||
`MARKDOWN_RETAINED=1` only in separate post-GC runs. The repository trace collector
|
||||
is available through `OPENCODE_PERFORMANCE_TRACE_DIR`, and `MARKDOWN_SCREENSHOT`
|
||||
captures final output after timing. Run serially, preserve frozen builds, and keep
|
||||
all results outside Git.
|
||||
125
packages/app/e2e/performance/markdown/lifetime.bench.ts
Normal file
125
packages/app/e2e/performance/markdown/lifetime.bench.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
import { completedAnswer } from "../../../../session-ui/performance/markdown-lifetime/answer"
|
||||
import { installMarkdownGate } from "./probe"
|
||||
|
||||
for (const size of ["typical", "large"]) {
|
||||
benchmark(`timeline preload disposal: ${size}`, async ({ page, report }) => {
|
||||
const answer = completedAnswer(size === "typical" ? 2 : 36)
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((id) => [
|
||||
id,
|
||||
[
|
||||
{
|
||||
id: `msg_1_${id}_user`,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review the recovery boundary.",
|
||||
},
|
||||
{
|
||||
id: `msg_2_${id}_assistant`,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000008000 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
id === fixture.targetID
|
||||
? answer
|
||||
: "## Current destination\n\nThe selected session is ready.\n\n```typescript\nconst current = { ready: true }\n```",
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
]),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions.filter((session) => session.id !== fixture.childID),
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (id) => ({ items: messages[id] ?? [] }),
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
const targetPart = `msg_2_${fixture.targetID}_assistant:text:0`
|
||||
const sourcePart = `msg_2_${fixture.sourceID}_assistant:text:0`
|
||||
await installMarkdownGate(page, { answer, targetPart, sourcePart, href: stressSessionHref(fixture.sourceID) })
|
||||
const prefetched = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await prefetched
|
||||
const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`)
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").held)
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached()
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
await page.evaluate(() => Reflect.get(window, "markdownGate").arm())
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0)
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").settled > 0)
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const stats = await page.evaluate(() => {
|
||||
const value = Reflect.get(window, "markdownGate")
|
||||
return {
|
||||
admitted: value.admitted,
|
||||
responses: value.responses,
|
||||
started: value.started,
|
||||
ready: value.ready,
|
||||
released: value.released,
|
||||
settled: value.settled,
|
||||
sanitizeCalls: value.sanitizeCalls,
|
||||
sanitizeChars: value.sanitizeChars,
|
||||
}
|
||||
})
|
||||
expect(stats.admitted).toBe(1)
|
||||
expect(stats.responses).toBe(1)
|
||||
expect(stats.ready).toBeGreaterThan(stats.started)
|
||||
expect(stats.settled).toBeGreaterThan(stats.released)
|
||||
expect(errors).toEqual([])
|
||||
if (process.env.MARKDOWN_ASSERT_DISPOSAL === "1") expect(stats.sanitizeCalls).toBe(0)
|
||||
const value = (data: typeof after, name: string) => data.metrics.find((item) => item.name === name)!.value
|
||||
const retained = process.env.MARKDOWN_RETAINED === "1"
|
||||
if (retained) await cdp.send("HeapProfiler.collectGarbage")
|
||||
report(
|
||||
{
|
||||
...stats,
|
||||
destinationReadyMs: stats.ready - stats.started,
|
||||
releasedSettledMs: stats.settled - stats.released,
|
||||
taskMs: (value(after, "TaskDuration") - value(before, "TaskDuration")) * 1000,
|
||||
scriptMs: (value(after, "ScriptDuration") - value(before, "ScriptDuration")) * 1000,
|
||||
usedHeapBytes: (await cdp.send("Runtime.getHeapUsage")).usedSize,
|
||||
},
|
||||
{
|
||||
size,
|
||||
retained,
|
||||
answerBytes: Buffer.byteLength(answer),
|
||||
messagesPerSession: 2,
|
||||
partsPerAnswer: 1,
|
||||
fences: size === "typical" ? 2 : 36,
|
||||
browser: page.context().browser()!.version(),
|
||||
transport: "playwright-route",
|
||||
build: process.env.MARKDOWN_APP_BUILD_DIR,
|
||||
},
|
||||
)
|
||||
if (process.env.MARKDOWN_SCREENSHOT)
|
||||
await page.screenshot({ path: `${process.env.MARKDOWN_SCREENSHOT}/timeline-${size}.png` })
|
||||
await cdp.detach()
|
||||
})
|
||||
}
|
||||
24
packages/app/e2e/performance/markdown/playwright.config.ts
Normal file
24
packages/app/e2e/performance/markdown/playwright.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from "@playwright/test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.env.PLAYWRIGHT_PORT = "6199"
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = "6199"
|
||||
process.env.PLAYWRIGHT_SERVER_HOST = "127.0.0.1"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.bench.ts",
|
||||
outputDir: process.env.MARKDOWN_RESULTS_DIR,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [["line"]],
|
||||
use: { baseURL: "http://127.0.0.1:6199", viewport: { width: 1280, height: 900 }, serviceWorkers: "block" },
|
||||
webServer: {
|
||||
cwd: fileURLToPath(new URL("../../..", import.meta.url)),
|
||||
command: `bun run serve -- --host 127.0.0.1 --port 6199 --strictPort --outDir "${process.env.MARKDOWN_APP_BUILD_DIR}"`,
|
||||
url: "http://127.0.0.1:6199",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
93
packages/app/e2e/performance/markdown/probe.ts
Normal file
93
packages/app/e2e/performance/markdown/probe.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { Page } from "@playwright/test"
|
||||
import type {
|
||||
MarkdownWorkerRequest,
|
||||
MarkdownWorkerResponse,
|
||||
} from "../../../../session-ui/src/components/markdown-worker-protocol"
|
||||
|
||||
export async function installMarkdownGate(
|
||||
page: Page,
|
||||
input: { answer: string; sourcePart: string; targetPart: string; href: string },
|
||||
) {
|
||||
await page.addInitScript(({ answer, sourcePart, targetPart, href }) => {
|
||||
const stats = {
|
||||
admitted: 0,
|
||||
responses: 0,
|
||||
held: false,
|
||||
started: 0,
|
||||
ready: 0,
|
||||
released: 0,
|
||||
settled: 0,
|
||||
sanitizeCalls: 0,
|
||||
sanitizeChars: 0,
|
||||
arm: () => {
|
||||
armed = true
|
||||
},
|
||||
}
|
||||
let armed = false
|
||||
let id: number | undefined
|
||||
let release: (() => void) | undefined
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, "onmessage")!
|
||||
const post = Worker.prototype.postMessage
|
||||
Object.defineProperty(Worker.prototype, "onmessage", {
|
||||
configurable: true,
|
||||
get: descriptor.get,
|
||||
set(callback: (event: MessageEvent<MarkdownWorkerResponse>) => void) {
|
||||
descriptor.set!.call(this, (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
if (event.data.type === "parse" && event.data.id === id) {
|
||||
stats.responses++
|
||||
stats.held = true
|
||||
release = () => callback.call(this, event)
|
||||
return
|
||||
}
|
||||
callback.call(this, event)
|
||||
})
|
||||
},
|
||||
})
|
||||
Worker.prototype.postMessage = function (request: MarkdownWorkerRequest) {
|
||||
if (request.type === "parse" && request.text === answer) {
|
||||
id = request.id
|
||||
stats.admitted++
|
||||
}
|
||||
post.call(this, request)
|
||||
}
|
||||
const parse = DOMParser.prototype.parseFromString
|
||||
DOMParser.prototype.parseFromString = function (text, type) {
|
||||
if (stats.released && String(text).includes("Recovery implementation review")) {
|
||||
stats.sanitizeCalls++
|
||||
stats.sanitizeChars += String(text).length
|
||||
}
|
||||
return parse.call(this, text, type)
|
||||
}
|
||||
document.addEventListener(
|
||||
"mousedown",
|
||||
(event) => {
|
||||
if (!armed || stats.started) return
|
||||
const target = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (target?.getAttribute("href") !== href) return
|
||||
stats.started = performance.now()
|
||||
},
|
||||
true,
|
||||
)
|
||||
// The app can retain the outgoing view until the destination is ready. Release
|
||||
// only after its actual row detaches, rather than assuming click means dispose.
|
||||
new MutationObserver(() => {
|
||||
if (!stats.started || stats.released) return
|
||||
const current = document.querySelector(`[data-timeline-part-id="${sourcePart}"] [data-markdown-ready]`)
|
||||
if (!current) return
|
||||
stats.ready ||= performance.now()
|
||||
if (document.querySelector(`[data-timeline-part-id="${targetPart}"]`)) return
|
||||
stats.released = performance.now()
|
||||
performance.mark("markdown-timeline-disposed")
|
||||
release!()
|
||||
release = undefined
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = () => {
|
||||
stats.settled = performance.now()
|
||||
channel.port1.close()
|
||||
channel.port2.close()
|
||||
}
|
||||
channel.port2.postMessage(null)
|
||||
}).observe(document, { childList: true, subtree: true, attributes: true })
|
||||
Object.defineProperty(window, "markdownGate", { value: stats })
|
||||
}, input)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
|
||||
|
|
@ -342,8 +342,11 @@ export function MessageTimeline(props: MessageTimelineProps) {
|
|||
if (message?.type === "assistant" && message.time.completed !== undefined) {
|
||||
const content = Timeline.resolveContent(message, tail.group.ref.partID)
|
||||
// Start the required worker job while the rest of the selected view is constructed.
|
||||
if (content?.type === "text" && content.text.trim())
|
||||
void preloadMarkdown(content.text, tail.group.ref.partID).catch(() => undefined)
|
||||
if (content?.type === "text" && content.text.trim()) {
|
||||
const preload = new AbortController()
|
||||
onCleanup(() => preload.abort())
|
||||
void preloadMarkdown(content.text, tail.group.ref.partID, preload.signal).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export {
|
|||
touchCachedMarkdown,
|
||||
} from "../src/components/markdown-cache"
|
||||
export { renderMermaidSvg } from "../src/components/markdown-mermaid"
|
||||
export { MarkdownWorkerDisposedError } from "../src/components/markdown-worker"
|
||||
export { preloadMarkdown }
|
||||
|
||||
export async function mountMarkdown(options: {
|
||||
text: string
|
||||
|
|
|
|||
|
|
@ -206,6 +206,104 @@ story("shares in-flight Markdown rendering without overwriting a reclaimed cache
|
|||
expect(result.html).toContain("<strong>Shared result</strong>")
|
||||
})
|
||||
|
||||
story("settles an abandoned parse and permits immediate cache-key reuse", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture)
|
||||
const controller = new AbortController()
|
||||
const raw = "```typescript\nconst abandoned = true\n```"
|
||||
const pending = renderCachedMarkdown({ raw, src: raw }, "released", controller.signal).catch(
|
||||
(error: unknown) => error instanceof MarkdownWorkerDisposedError,
|
||||
)
|
||||
controller.abort()
|
||||
const rejected = await pending
|
||||
const empty = getCachedMarkdown("released") === undefined
|
||||
const replacement = "```typescript\nconst replacement = true\n```"
|
||||
const rendered = await renderCachedMarkdown({ raw: replacement, src: replacement }, "released")
|
||||
return { rejected, empty, cached: getCachedMarkdown("released") === rendered, html: rendered.html }
|
||||
}, fixture)
|
||||
expect(result).toMatchObject({ rejected: true, empty: true, cached: true })
|
||||
expect(result.html).toContain("replacement")
|
||||
expect(result.html).not.toContain("abandoned")
|
||||
})
|
||||
|
||||
for (const owned of [true, false]) {
|
||||
story(
|
||||
`preserves a shared parse for ${owned ? "another mounted consumer" : "an explicit preload"}`,
|
||||
async ({ page }) => {
|
||||
const result = await page.evaluate(
|
||||
async ({ fixture, owned }) => {
|
||||
const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture)
|
||||
const first = new AbortController()
|
||||
const second = new AbortController()
|
||||
const raw = "```typescript\nconst shared = true\n```"
|
||||
const pending = renderCachedMarkdown({ raw, src: raw }, "shared-lifetime", first.signal).catch(
|
||||
(error: unknown) => error instanceof MarkdownWorkerDisposedError,
|
||||
)
|
||||
let complete = false
|
||||
const survivor = renderCachedMarkdown(
|
||||
{ raw, src: raw },
|
||||
"shared-lifetime",
|
||||
owned ? second.signal : undefined,
|
||||
).then((value: { html: string }) => {
|
||||
complete = true
|
||||
return value
|
||||
})
|
||||
first.abort()
|
||||
const rejected = await pending
|
||||
const independent = !complete
|
||||
const rendered = await survivor
|
||||
second.abort()
|
||||
return {
|
||||
rejected,
|
||||
independent,
|
||||
cached: getCachedMarkdown("shared-lifetime") === rendered,
|
||||
html: rendered.html,
|
||||
}
|
||||
},
|
||||
{ fixture, owned },
|
||||
)
|
||||
expect(result).toMatchObject({ rejected: true, independent: true, cached: true })
|
||||
expect(result.html).toContain("shared")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
story("does not admit an already disposed Markdown consumer", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const raw = "```typescript\nconst ignored = true\n```"
|
||||
const rejected = await renderCachedMarkdown({ raw, src: raw }, "already-disposed", controller.signal).then(
|
||||
() => false,
|
||||
(error: unknown) => error instanceof MarkdownWorkerDisposedError,
|
||||
)
|
||||
return { rejected, empty: getCachedMarkdown("already-disposed") === undefined }
|
||||
}, fixture)
|
||||
expect(result).toEqual({ rejected: true, empty: true })
|
||||
})
|
||||
|
||||
story("releases a timeline preload without cancelling a mounted shared consumer", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { getCachedMarkdown, preloadMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(
|
||||
fixture
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const raw = "```typescript\nconst preloaded = true\n```"
|
||||
const preload = preloadMarkdown(raw, "timeline-preload", controller.signal).then(
|
||||
() => false,
|
||||
(error: unknown) => error instanceof MarkdownWorkerDisposedError,
|
||||
)
|
||||
const mounted = renderCachedMarkdown({ raw, src: raw }, "timeline-preload:0:full")
|
||||
controller.abort()
|
||||
const rejected = await preload
|
||||
const rendered = await mounted
|
||||
return { rejected, cached: getCachedMarkdown("timeline-preload:0:full") === rendered, html: rendered.html }
|
||||
}, fixture)
|
||||
expect(result).toMatchObject({ rejected: true, cached: true })
|
||||
expect(result.html).toContain("preloaded")
|
||||
})
|
||||
|
||||
story("keeps a reopened cached answer recent under cache pressure", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountMarkdown, touchCachedMarkdown } = await import(fixture)
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@
|
|||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite": "catalog:"
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-solid": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
|
|
|
|||
50
packages/session-ui/performance/markdown-lifetime/README.md
Normal file
50
packages/session-ui/performance/markdown-lifetime/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Completed Markdown Job Lifetime
|
||||
|
||||
This manual browser benchmark bundles the production `Markdown` component,
|
||||
cache, sanitizer, and worker with Vite's production mode. It does not connect to
|
||||
an OpenCode server or use user data. Run from `packages/session-ui`.
|
||||
|
||||
Set `MARKDOWN_BUILD_DIR` and `MARKDOWN_RESULTS_DIR` to absolute artifact directories:
|
||||
|
||||
```sh
|
||||
bun --bun x vite build --config performance/markdown-lifetime/vite.config.ts
|
||||
bun --bun x playwright test --config performance/markdown-lifetime/playwright.config.ts --repeat-each 20
|
||||
```
|
||||
|
||||
The three independent scenarios render one deterministic long completed answer
|
||||
with 36 TypeScript fences, explanatory prose, links, inline code, and tables:
|
||||
|
||||
- `mounted`: keep the consumer mounted through result delivery.
|
||||
- `leave`: unmount the last consumer before delivering its parse result and render
|
||||
a short completed answer in the current destination.
|
||||
- `shared`: unmount one of two consumers with the same cache key, preserving the
|
||||
other consumer's result.
|
||||
|
||||
The fixture intercepts only the real worker's message delivery. Admission and
|
||||
worker-result arrival enable the Continue button. Continue disposes the departing
|
||||
consumer, then delivers the held result. No parser work is replaced. This makes
|
||||
the unmount/result ordering deterministic without wall-clock delays. Cold worker
|
||||
startup and parsing happen before the measured interval; this benchmark measures
|
||||
main-thread work after the worker has produced a result, not worker CPU savings.
|
||||
|
||||
Readiness is the production component's `data-markdown-ready` state, observed by
|
||||
a MutationObserver. A MessageChannel task marks completion of the released
|
||||
result's promise microtasks, including cache postprocessing. CDP reports renderer
|
||||
task/script duration and renderer JS heap, not total browser or desktop RAM.
|
||||
The cache-character metric confirms whether the obsolete result was sanitized and
|
||||
stored. It is a mechanism check, not the performance result by itself.
|
||||
|
||||
The initial frozen baseline called this field `cacheBytes`, although it counted
|
||||
HTML characters. The runner normalizes that frozen-build field to `cacheChars`;
|
||||
the renderer probe operation is unchanged.
|
||||
|
||||
Use `MARKDOWN_ASSERT_DISPOSAL=1` to assert that the abandoned result is not cached.
|
||||
Use `MARKDOWN_RETAINED=1` in separate runs for a post-GC retention check; do not mix
|
||||
these samples with natural-GC heap measurements. `OPENCODE_PERFORMANCE_TRACE_DIR`
|
||||
enables the repository's Chrome trace collector. `MARKDOWN_SCREENSHOT` optionally
|
||||
captures each final scenario after timing. All artifacts belong outside Git.
|
||||
|
||||
Freeze the baseline build before changing production source and reuse the same
|
||||
fixture, runtime, viewport, and readiness contracts for the candidate. Do not use
|
||||
machine-dependent latency thresholds. The scenarios test real components, but do
|
||||
not reproduce full session navigation or establish Electron process-memory gains.
|
||||
13
packages/session-ui/performance/markdown-lifetime/answer.ts
Normal file
13
packages/session-ui/performance/markdown-lifetime/answer.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export function completedAnswer(sections: number) {
|
||||
return `# Recovery implementation review\n\n${Array.from({ length: sections }, (_, index) => {
|
||||
const service = ["catalog", "billing", "delivery", "inventory", "accounts", "notifications"][index % 6]
|
||||
return [
|
||||
`## ${index + 1}. Validate the ${service} recovery boundary`,
|
||||
`The ${service} service should publish durable progress before acknowledging a request. Keep the request ID in the transaction so a retry does not create a second operation. The implementation below separates admission from delivery and makes the recovery decision explicit.`,
|
||||
"Check the existing rows before scheduling work. A disconnected client is not evidence that the operation failed, and the background processor must not delete accepted work when a view closes. Use the stored status for the next attempt, not a process-local flag.",
|
||||
`\`\`\`typescript\nexport async function recover${index}(db: Database, request: Request) {\n const previous = await db.operations.find(request.id)\n if (previous?.status === "complete") return previous.result\n const operation = previous ?? await db.transaction(async (tx) => {\n const row = await tx.operations.insert({\n id: request.id,\n service: "${service}",\n status: "accepted",\n payload: request.payload,\n })\n await tx.events.publish({ type: "operation.accepted", id: row.id })\n return row\n })\n await schedule(operation.id)\n return { id: operation.id, status: operation.status }\n}\n\`\`\``,
|
||||
"| Condition | Expected behavior |\n| --- | --- |\n| Duplicate request | Return the first accepted result |\n| Worker restart | Resume the stored operation |\n| Client leaves | Retain accepted work without retaining the view |",
|
||||
`Run the focused test with \`bun test test/${service}/recovery.test.ts\`. Verify the [transaction contract](https://example.com/transactions) and inspect the operation's final state before expanding the rollout.`,
|
||||
].join("\n\n")
|
||||
}).join("\n\n")}\n\n**Review complete.**`
|
||||
}
|
||||
130
packages/session-ui/performance/markdown-lifetime/fixture.tsx
Normal file
130
packages/session-ui/performance/markdown-lifetime/fixture.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { createSignal, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { Markdown } from "../../src/components/markdown"
|
||||
import { getCachedMarkdown } from "../../src/components/markdown-cache"
|
||||
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from "../../src/components/markdown-worker-protocol"
|
||||
import "@opencode-ai/ui/styles"
|
||||
import "@opencode-ai/ui/styles/tokens"
|
||||
import "../../src/components/markdown.css"
|
||||
|
||||
const scenario = new URLSearchParams(location.search).get("scenario") ?? "mounted"
|
||||
const sections = Array.from({ length: 36 }, (_, index) => {
|
||||
const service = ["catalog", "billing", "delivery", "inventory", "accounts", "notifications"][index % 6]
|
||||
return [
|
||||
`## ${index + 1}. Validate the ${service} recovery boundary`,
|
||||
`The ${service} service should publish durable progress before acknowledging a request. Keep the request ID in the transaction so a retry does not create a second operation. The implementation below separates admission from delivery and makes the recovery decision explicit.`,
|
||||
"Check the existing rows before scheduling work. A disconnected client is not evidence that the operation failed, and the background processor must not delete accepted work when a view closes. Use the stored status for the next attempt, not a process-local flag.",
|
||||
`\`\`\`typescript\nexport async function recover${index}(db: Database, request: Request) {\n const previous = await db.operations.find(request.id)\n if (previous?.status === "complete") return previous.result\n const operation = previous ?? await db.transaction(async (tx) => {\n const row = await tx.operations.insert({\n id: request.id,\n service: "${service}",\n status: "accepted",\n payload: request.payload,\n })\n await tx.events.publish({ type: "operation.accepted", id: row.id })\n return row\n })\n await schedule(operation.id)\n return { id: operation.id, status: operation.status }\n}\n\`\`\``,
|
||||
"| Condition | Expected behavior |\n| --- | --- |\n| Duplicate request | Return the first accepted result |\n| Worker restart | Resume the stored operation |\n| Client leaves | Retain accepted work without retaining the view |",
|
||||
`Run the focused test with \`bun test test/${service}/recovery.test.ts\`. Verify the [transaction contract](https://example.com/transactions) and inspect the operation's final state before expanding the rollout.`,
|
||||
].join("\n\n")
|
||||
})
|
||||
const answer = `# Recovery implementation review\n\n${sections.join("\n\n")}\n\n**Review complete.**`
|
||||
const destination =
|
||||
"## Current destination\n\nThe new session is ready.\n\n```typescript\nconst current = { ready: true }\n```"
|
||||
const stats = {
|
||||
bytes: new TextEncoder().encode(answer).length,
|
||||
fences: sections.length,
|
||||
messages: 1,
|
||||
parts: 1,
|
||||
requests: 0,
|
||||
responses: 0,
|
||||
released: 0,
|
||||
ready: 0,
|
||||
settled: 0,
|
||||
disposed: false,
|
||||
cacheChars: 0,
|
||||
}
|
||||
|
||||
// Keep the real worker and parser. Only hold delivery of this answer's result so
|
||||
// disposal always happens after admission and before main-thread postprocessing.
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, "onmessage")!
|
||||
const post = Worker.prototype.postMessage
|
||||
let held: (() => void) | undefined
|
||||
let answerID: number | undefined
|
||||
Object.defineProperty(Worker.prototype, "onmessage", {
|
||||
configurable: true,
|
||||
get: descriptor.get,
|
||||
set(callback: (event: MessageEvent<MarkdownWorkerResponse>) => void) {
|
||||
descriptor.set!.call(this, (event: MessageEvent<MarkdownWorkerResponse>) => {
|
||||
if (event.data.type === "parse" && event.data.id === answerID) {
|
||||
stats.responses++
|
||||
held = () => callback.call(this, event)
|
||||
document.querySelector<HTMLButtonElement>("#continue")!.disabled = false
|
||||
return
|
||||
}
|
||||
callback.call(this, event)
|
||||
})
|
||||
},
|
||||
})
|
||||
Worker.prototype.postMessage = function (request: MarkdownWorkerRequest) {
|
||||
if (request.type === "parse" && request.text === answer) {
|
||||
answerID = request.id
|
||||
stats.requests++
|
||||
}
|
||||
post.call(this, request)
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!stats.released || stats.ready) return
|
||||
const target = document.querySelector(scenario === "leave" ? "#destination" : "#survivor")
|
||||
if (!target?.hasAttribute("data-markdown-ready")) return
|
||||
stats.ready = performance.now()
|
||||
document.body.dataset.ready = "true"
|
||||
})
|
||||
observer.observe(document.body, { subtree: true, attributes: true, childList: true })
|
||||
|
||||
render(() => {
|
||||
const [admitted, setAdmitted] = createSignal(false)
|
||||
const [leaving, setLeaving] = createSignal(false)
|
||||
return (
|
||||
<main style={{ "max-width": "960px", margin: "24px auto", "font-family": "sans-serif", "line-height": "1.5" }}>
|
||||
<button id="admit" onClick={() => setAdmitted(true)} disabled={admitted()}>
|
||||
Admit answer
|
||||
</button>
|
||||
<button
|
||||
id="continue"
|
||||
disabled
|
||||
onClick={() => {
|
||||
stats.released = performance.now()
|
||||
performance.mark("markdown-lifetime-release")
|
||||
if (scenario !== "mounted") {
|
||||
stats.disposed = true
|
||||
setLeaving(true)
|
||||
}
|
||||
held!()
|
||||
held = undefined
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = () => {
|
||||
stats.settled = performance.now()
|
||||
stats.cacheChars = getCachedMarkdown("lifetime:0:full")?.html.length ?? 0
|
||||
document.body.dataset.settled = "true"
|
||||
channel.port1.close()
|
||||
channel.port2.close()
|
||||
}
|
||||
channel.port2.postMessage(null)
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
<Show when={admitted()}>
|
||||
<Show when={!leaving()}>
|
||||
<Markdown
|
||||
id={scenario === "shared" ? "departing" : "survivor"}
|
||||
text={answer}
|
||||
cacheKey="lifetime"
|
||||
deferUntilReady
|
||||
/>
|
||||
</Show>
|
||||
<Show when={scenario === "shared"}>
|
||||
<Markdown id="survivor" text={answer} cacheKey="lifetime" deferUntilReady />
|
||||
</Show>
|
||||
<Show when={leaving() && scenario === "leave"}>
|
||||
<Markdown id="destination" text={destination} cacheKey="destination" deferUntilReady />
|
||||
</Show>
|
||||
</Show>
|
||||
</main>
|
||||
)
|
||||
}, document.getElementById("root")!)
|
||||
|
||||
Object.defineProperty(window, "markdownLifetime", { value: stats })
|
||||
11
packages/session-ui/performance/markdown-lifetime/index.html
Normal file
11
packages/session-ui/performance/markdown-lifetime/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Markdown lifetime fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { benchmark, expect } from "../../../app/e2e/performance/benchmark"
|
||||
|
||||
for (const scenario of ["mounted", "leave", "shared"]) {
|
||||
benchmark(`completed Markdown: ${scenario}`, async ({ page, report }) => {
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
await page.goto(`/?scenario=${scenario}`)
|
||||
await page.getByRole("button", { name: "Admit answer" }).click()
|
||||
await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeEnabled()
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send("Performance.enable")
|
||||
const before = await session.send("Performance.getMetrics")
|
||||
await page.getByRole("button", { name: "Continue", exact: true }).click()
|
||||
await expect(page.locator("body")).toHaveAttribute("data-ready", "true")
|
||||
await expect(page.locator("body")).toHaveAttribute("data-settled", "true")
|
||||
const after = await session.send("Performance.getMetrics")
|
||||
const stats = await page.evaluate(() => Reflect.get(window, "markdownLifetime"))
|
||||
// The frozen initial baseline used a byte label for this character count.
|
||||
stats.cacheChars ??= stats.cacheBytes
|
||||
delete stats.cacheBytes
|
||||
expect(stats.requests).toBe(1)
|
||||
expect(stats.responses).toBe(1)
|
||||
expect(stats.ready).toBeGreaterThan(stats.released)
|
||||
expect(stats.settled).toBeGreaterThan(stats.released)
|
||||
expect(errors).toEqual([])
|
||||
if (scenario === "leave") {
|
||||
await expect(page.locator("#survivor")).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: "Current destination" })).toBeVisible()
|
||||
if (process.env.MARKDOWN_ASSERT_DISPOSAL === "1") expect(stats.cacheChars).toBe(0)
|
||||
} else {
|
||||
await expect(page.locator("#survivor pre code")).toHaveCount(stats.fences)
|
||||
await expect(page.locator("#survivor")).toContainText("Review complete.")
|
||||
expect(stats.cacheChars).toBeGreaterThan(0)
|
||||
if (scenario === "shared") await expect(page.locator("#departing")).toHaveCount(0)
|
||||
}
|
||||
const value = (data: typeof after, name: string) => data.metrics.find((item) => item.name === name)!.value
|
||||
const retained = process.env.MARKDOWN_RETAINED === "1"
|
||||
if (retained) await session.send("HeapProfiler.collectGarbage")
|
||||
const heap = await session.send("Runtime.getHeapUsage")
|
||||
report(
|
||||
{
|
||||
...stats,
|
||||
readyMs: stats.ready - stats.released,
|
||||
settledMs: stats.settled - stats.released,
|
||||
taskMs: (value(after, "TaskDuration") - value(before, "TaskDuration")) * 1000,
|
||||
scriptMs: (value(after, "ScriptDuration") - value(before, "ScriptDuration")) * 1000,
|
||||
usedHeapBytes: heap.usedSize,
|
||||
},
|
||||
{ scenario, retained, build: process.env.MARKDOWN_BUILD_DIR, revision: process.env.MARKDOWN_REVISION },
|
||||
)
|
||||
if (process.env.MARKDOWN_SCREENSHOT)
|
||||
await page.screenshot({ path: `${process.env.MARKDOWN_SCREENSHOT}/${scenario}.png` })
|
||||
await session.detach()
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from "@playwright/test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "*.bench.ts",
|
||||
outputDir: process.env.MARKDOWN_RESULTS_DIR,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 60_000,
|
||||
reporter: [["line"]],
|
||||
use: { baseURL: "http://127.0.0.1:6197", viewport: { width: 1280, height: 900 } },
|
||||
webServer: {
|
||||
cwd: fileURLToPath(new URL("../..", import.meta.url)),
|
||||
command:
|
||||
"bun --bun x vite preview --config performance/markdown-lifetime/vite.config.ts --host 127.0.0.1 --port 6197 --strictPort",
|
||||
url: "http://127.0.0.1:6197",
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
plugins: [solid()],
|
||||
build: { outDir: process.env.MARKDOWN_BUILD_DIR, emptyOutDir: true, sourcemap: true },
|
||||
worker: { format: "es" },
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { parseSmallMarkdown } from "@opencode-ai/ui/context/marked-base"
|
||||
import DOMPurify from "dompurify"
|
||||
import { parseMarkdown } from "./markdown-worker"
|
||||
import { MarkdownWorkerDisposedError, parseMarkdown } from "./markdown-worker"
|
||||
import { localImagePath } from "./markdown-image"
|
||||
|
||||
export type MarkdownCacheEntry = {
|
||||
|
|
@ -12,7 +12,10 @@ export type MarkdownCacheEntry = {
|
|||
|
||||
const max = 200
|
||||
const cache = new Map<string, MarkdownCacheEntry>()
|
||||
const pending = new Map<string, { raw: string; promise: Promise<MarkdownCacheEntry> }>()
|
||||
const pending = new Map<
|
||||
string,
|
||||
{ raw: string; promise: Promise<MarkdownCacheEntry>; controller: AbortController; consumers: Set<symbol> }
|
||||
>()
|
||||
// Mermaid registers hooks on the shared instance that overwrite link attributes.
|
||||
const purifier = typeof window !== "undefined" ? DOMPurify(window) : DOMPurify
|
||||
const config = {
|
||||
|
|
@ -67,11 +70,12 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
|
|||
cache.delete(first)
|
||||
}
|
||||
|
||||
export async function preloadMarkdown(text: string, cacheKey: string) {
|
||||
export async function preloadMarkdown(text: string, cacheKey: string, signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw new MarkdownWorkerDisposedError()
|
||||
const block = { raw: text, src: text }
|
||||
const key = `${cacheKey}:0:full`
|
||||
if (getReadyMarkdown(block, key)) return
|
||||
await renderCachedMarkdown(block, key)
|
||||
await renderCachedMarkdown(block, key, signal)
|
||||
}
|
||||
|
||||
export function getReadyMarkdown(block: { raw: string; src: string }, key?: string) {
|
||||
|
|
@ -98,7 +102,8 @@ export function getReadyMarkdown(block: { raw: string; src: string }, key?: stri
|
|||
}
|
||||
}
|
||||
|
||||
export async function renderCachedMarkdown(block: { raw: string; src: string }, key?: string) {
|
||||
export async function renderCachedMarkdown(block: { raw: string; src: string }, key?: string, signal?: AbortSignal) {
|
||||
if (signal?.aborted) throw new MarkdownWorkerDisposedError()
|
||||
const cached = key ? getCachedMarkdown(key) : undefined
|
||||
if (key && cached?.raw === block.raw) {
|
||||
pending.delete(key)
|
||||
|
|
@ -106,9 +111,31 @@ export async function renderCachedMarkdown(block: { raw: string; src: string },
|
|||
return cached
|
||||
}
|
||||
const current = key ? pending.get(key) : undefined
|
||||
if (current?.raw === block.raw) return current.promise
|
||||
const promise = parseMarkdown(block.src)
|
||||
const job = current?.raw === block.raw ? current : startMarkdown(block, key)
|
||||
const consumer = Symbol()
|
||||
job.consumers.add(consumer)
|
||||
return new Promise<MarkdownCacheEntry>((resolve, reject) => {
|
||||
const release = () => {
|
||||
signal?.removeEventListener("abort", abort)
|
||||
// A disposed consumer must not cancel another consumer's shared parse.
|
||||
if (!job.consumers.delete(consumer) || job.consumers.size > 0) return
|
||||
job.controller.abort()
|
||||
if (key && pending.get(key) === job) pending.delete(key)
|
||||
}
|
||||
const abort = () => {
|
||||
release()
|
||||
reject(new MarkdownWorkerDisposedError())
|
||||
}
|
||||
signal?.addEventListener("abort", abort, { once: true })
|
||||
void job.promise.then(resolve, reject).finally(release)
|
||||
})
|
||||
}
|
||||
|
||||
function startMarkdown(block: { raw: string; src: string }, key?: string) {
|
||||
const controller = new AbortController()
|
||||
const promise = parseMarkdown(block.src, controller.signal)
|
||||
.then((html) => {
|
||||
if (controller.signal.aborted) throw new MarkdownWorkerDisposedError()
|
||||
const hash = checksum(block.raw)
|
||||
const result = { raw: block.raw, hash: hash ?? "", html: sanitizeMarkdown(html) }
|
||||
if (key && hash && pending.get(key)?.promise === promise) touchCachedMarkdown(key, result)
|
||||
|
|
@ -117,6 +144,7 @@ export async function renderCachedMarkdown(block: { raw: string; src: string },
|
|||
.finally(() => {
|
||||
if (key && pending.get(key)?.promise === promise) pending.delete(key)
|
||||
})
|
||||
if (key) pending.set(key, { raw: block.raw, promise })
|
||||
return promise
|
||||
const job = { raw: block.raw, promise, controller, consumers: new Set<symbol>() }
|
||||
if (key) pending.set(key, job)
|
||||
return job
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,12 +54,21 @@ const projectTransport = createWorkerTransport<Extract<MarkdownWorkerRequest, {
|
|||
},
|
||||
})
|
||||
|
||||
export function parseMarkdown(text: string) {
|
||||
export function parseMarkdown(text: string, signal: AbortSignal) {
|
||||
if (signal.aborted) return Promise.reject(new MarkdownWorkerDisposedError())
|
||||
const instance = getWorker()
|
||||
const id = ++nextID
|
||||
const abort = () => {
|
||||
parses.get(id)?.reject(new MarkdownWorkerDisposedError())
|
||||
parses.delete(id)
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
parses.set(id, { resolve, reject })
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
instance.postMessage({ type: "parse", id, text } satisfies MarkdownWorkerRequest)
|
||||
}).finally(() => {
|
||||
signal.removeEventListener("abort", abort)
|
||||
parses.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -396,6 +396,7 @@ export function Markdown(
|
|||
const markdown = useMarkdown()
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const owner = createUniqueId()
|
||||
const lifetime = new AbortController()
|
||||
const activeCodeKeys = new Set<string>()
|
||||
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
|
||||
let streamed = false
|
||||
|
|
@ -481,25 +482,31 @@ export function Markdown(
|
|||
}
|
||||
|
||||
const ready = block.mode === "full" ? getReadyMarkdown(block, key) : undefined
|
||||
return { key: blockKey, mode: block.mode, ...(ready ?? (await renderCachedMarkdown(block, key))) }
|
||||
return {
|
||||
key: blockKey,
|
||||
mode: block.mode,
|
||||
...(ready ?? (await renderCachedMarkdown(block, key, lifetime.signal))),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.then((blocks) => ({ text: src.text, blocks, ready: true }) satisfies RenderResult)
|
||||
.catch(
|
||||
() =>
|
||||
({
|
||||
text: src.text,
|
||||
ready: true,
|
||||
blocks: [
|
||||
{
|
||||
key: base ?? "fallback",
|
||||
mode: "full" as const,
|
||||
raw: src.text,
|
||||
hash: checksum(src.text) ?? "",
|
||||
html: fallback(src.text),
|
||||
},
|
||||
],
|
||||
}) satisfies RenderResult,
|
||||
(lifetime.signal.aborted
|
||||
? { text: src.text, blocks: [], ready: false }
|
||||
: {
|
||||
text: src.text,
|
||||
ready: true,
|
||||
blocks: [
|
||||
{
|
||||
key: base ?? "fallback",
|
||||
mode: "full" as const,
|
||||
raw: src.text,
|
||||
hash: checksum(src.text) ?? "",
|
||||
html: fallback(src.text),
|
||||
},
|
||||
],
|
||||
}) satisfies RenderResult,
|
||||
)
|
||||
},
|
||||
{ initialValue: initial },
|
||||
|
|
@ -562,6 +569,7 @@ export function Markdown(
|
|||
})
|
||||
|
||||
onCleanup(() => {
|
||||
lifetime.abort()
|
||||
images?.dispose()
|
||||
if (copyCleanup) copyCleanup()
|
||||
const container = root()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue