Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ file format introduced in `[0.0.1.0]` was dropped.

## [Unreleased]

## [0.8.6] - 2026-06-29

### Fixed

- **Flaky graph e2e (`graph.spec.ts > renders a nonblank Pixi graph canvas`) —
root-caused, not retried away.** In React StrictMode's dev double-invoke, the
graph mounts two `createPixiGraphEngine()` inits concurrently. The canvas was
attached via `mount.replaceChildren(app.canvas)` _inside the async factory_,
outside the effect's `active` guard, so a slower-resolving **stale** init could
clobber the live engine's canvas and then empty the mount when its `destroy()`
fired — leaving `data-ready="true"` over a canvas-less stage. `GraphCanvas` now
attaches the canvas **only inside the active guard** (the engine exposes a
`canvas` getter), so the discarded init never touches the DOM. Production
rendering is unchanged (no StrictMode double-invoke there → single attach). The
spec also gates on `data-render-count >= 1` before reading the canvas contract.
- **Flaky Mermaid disclosure clicks (`wiki.spec.ts`, `theme.spec.ts`).** A bare
`getByText("flowchart")` also matched the `<style>` Mermaid injects (its CSS
mentions `flowchart`), so once any diagram had rendered the locator resolved to
two nodes and `.click()` threw a Playwright strict-mode violation. Both specs
now scope to the `<summary>` element (`locator("summary", { hasText: … })`).

## [0.8.5] - 2026-06-28

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dikw-web",
"version": "0.8.5",
"version": "0.8.6",
"private": true,
"type": "module",
"engines": {
Expand Down
14 changes: 6 additions & 8 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,12 @@ export default defineConfig({
testIgnore: live ? undefined : ["**/live/**"],
fullyParallel: true,
reporter: "list",
// CI-only retries to absorb a documented Pixi/StrictMode race in
// `graph.spec.ts > renders a nonblank Pixi graph canvas`: the
// create-pixi -> destroy -> recreate cycle from React StrictMode dev mode
// can leave the canvas mount empty after `data-ready=true` flips. Locally
// the test usually passes on the first attempt; CI runners (headless
// chromium on Ubuntu) hit it more reliably. Two retries empirically
// absorb every observed occurrence without masking actual regressions —
// the test is fast (~10s) and unrelated to this codebase's logic.
// CI-only retries, kept as a general backstop for timing-sensitive specs on
// shared CI runners. The Pixi/StrictMode canvas race that previously made
// `graph.spec.ts > renders a nonblank Pixi graph canvas` flaky is now fixed at
// the source — GraphCanvas attaches the live engine's canvas only inside its
// active guard, so a slower stale StrictMode init can no longer empty the mount.
// Retries absorb residual environmental jitter without masking real regressions.
retries: process.env.CI ? 2 : 0,
use: {
baseURL: live
Expand Down
12 changes: 11 additions & 1 deletion src/components/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface GraphPalette {
}

interface PixiEngine {
readonly canvas: HTMLCanvasElement;
render(graph: PositionedGalaxyGraph, state: RenderState): void;
resize(width: number, height: number): void;
destroy(): void;
Expand Down Expand Up @@ -79,6 +80,12 @@ export function GraphCanvas({
return;
}
engineRef.current = engine;
// Attach only the active engine's canvas. StrictMode's dev double-invoke
// runs two createPixiGraphEngine() inits concurrently; doing the mount
// attach here (inside the active guard) — not inside the async factory —
// keeps a slower-resolving stale init from clobbering the live canvas and
// then emptying the mount when its destroy() fires.
mount.replaceChildren(engine.canvas);
engine.resize(size.width, size.height);
setPixiReady(true);
})
Expand Down Expand Up @@ -158,7 +165,6 @@ async function createPixiGraphEngine(mount: HTMLElement): Promise<PixiEngine> {
resolution: Math.min(window.devicePixelRatio || 1, 2),
powerPreference: "high-performance",
});
mount.replaceChildren(app.canvas);
return new PixiGraphEngine(app, pixi);
}

Expand Down Expand Up @@ -194,6 +200,10 @@ class PixiGraphEngine implements PixiEngine {
this.installCamera();
}

get canvas(): HTMLCanvasElement {
return this.app.canvas as HTMLCanvasElement;
}

render(graph: PositionedGalaxyGraph, state: RenderState): void {
this.edgeLayer.clear();
this.nodeLayer.clear();
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/graph.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ test("renders a nonblank Pixi graph canvas", async ({ page }) => {
await expect(page.locator('.graph-pixi-mount[data-ready="true"]')).toBeVisible({
timeout: 15000,
});
// The StrictMode attach race that could leave this mount empty is fixed in
// GraphCanvas (canvas is attached only inside the active guard). Still gate on
// data-render-count (incremented only after engine.render() actually draws) — the
// same signal the sibling test uses — so the contract read below runs against a
// populated stage rather than a stage whose canvas exists but hasn't drawn yet.
await expect
.poll(
async () =>
Number(await page.locator(".graph-pixi-stage").getAttribute("data-render-count")) || 0,
{ timeout: 5000 },
)
.toBeGreaterThanOrEqual(1);
await page.getByLabel("Search graph").focus();

const canvasContract = await page.evaluate(() => {
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ test("dark Wiki reader keeps details and Mermaid diagrams on reader surfaces", a
await page.getByLabel("Filter").fill("active-learning");
await page.getByRole("treeitem", { name: "Active Learning Medium" }).getByRole("button").click();
const reader = page.locator(".wiki-reader");
await reader.getByText("flowchart").click();
// Scope to <summary>: a bare getByText("flowchart") also matches Mermaid's injected
// <style> once a diagram has rendered, yielding a strict-mode violation (see wiki.spec.ts).
await reader.locator("summary", { hasText: "flowchart" }).click();

await expect(reader.locator(".markdown-details")).toBeVisible();
await expect(reader.locator(".mermaid-diagram svg")).toBeVisible();
Expand Down
6 changes: 5 additions & 1 deletion tests/e2e/wiki.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ test("renders source details blocks with Mermaid diagrams", async ({ page }) =>
await expect(reader.getByRole("heading", { name: "Active Learning Medium" })).toBeVisible();
await expect(reader).not.toContainText("<details>");

await reader.getByText("flowchart").click();
// Scope to the <summary> element: a bare getByText("flowchart") also matches the
// <style> Mermaid injects (its CSS mentions "flowchart"), so once any diagram has
// rendered the locator resolves to 2 nodes and .click() throws a strict-mode
// violation — the documented order/timing-dependent flake.
await reader.locator("summary", { hasText: "flowchart" }).click();
await expect(reader.locator(".markdown-details")).toBeVisible();
await expect(reader.locator(".mermaid-diagram svg")).toBeVisible();
await expect(page).toHaveURL(/#base$/);
Expand Down