diff --git a/.changeset/state-note-edge-neo-dashes.md b/.changeset/state-note-edge-neo-dashes.md new file mode 100644 index 00000000000..9e792e59b78 --- /dev/null +++ b/.changeset/state-note-edge-neo-dashes.md @@ -0,0 +1,7 @@ +--- +'mermaid': patch +--- + +fix(state): draw the edge between a state and its note dashed under the `neo` look. It was rendering solid. + +The dashes were expressed only as CSS — `.note-edge { stroke-dasharray: 5 }` — which is enough under `classic` but not under `neo`, where `insertEdge` writes an inline `stroke-dasharray` on every edge, computed from the path length so the arrow markers keep their gaps. An inline style outranks a stylesheet rule, so the note edge took the solid pattern and the `.note-edge` rule simply lost. The note edge now declares `pattern: 'dashed'`, which is what `insertEdge` reads to choose its dash generator. `classic` is unchanged. diff --git a/packages/mermaid/src/diagrams/state/dataFetcher.ts b/packages/mermaid/src/diagrams/state/dataFetcher.ts index 76212656207..8b4bf27a358 100644 --- a/packages/mermaid/src/diagrams/state/dataFetcher.ts +++ b/packages/mermaid/src/diagrams/state/dataFetcher.ts @@ -373,6 +373,18 @@ export const dataFetcher = ( style: G_EDGE_STYLE, labelStyle: '', classes: CSS_EDGE_NOTE_EDGE, + // The dashes have to be declared on the edge, not only through the `note-edge` + // class. Under `look: neo`, `insertEdge` writes an *inline* `stroke-dasharray` + // computed from the path length -- a solid run trimmed at both ends so the arrow + // markers get their gaps -- and it picks that pattern from `edge.pattern`. An + // inline style outranks the stylesheet, so a note edge that only carried the class + // was drawn solid: the `.note-edge` rule was still there and simply lost. + // + // Naming the pattern here routes it through the same dash generator every other + // dashed edge uses, so the marker gaps survive. `classic` is untouched: it writes + // no inline dasharray, and `.note-edge` still wins over `edge-pattern-dashed` + // because it is emitted later in the sheet at equal specificity. + pattern: 'dashed', arrowheadStyle: G_EDGE_ARROWHEADSTYLE, labelpos: G_EDGE_LABELPOS, labelType: G_EDGE_LABELTYPE, diff --git a/packages/mermaid/src/diagrams/state/stateDb.ts b/packages/mermaid/src/diagrams/state/stateDb.ts index b39d1f81d61..8c3953ca61b 100644 --- a/packages/mermaid/src/diagrams/state/stateDb.ts +++ b/packages/mermaid/src/diagrams/state/stateDb.ts @@ -182,6 +182,11 @@ export interface Edge { thickness: string; classes: string; look: MermaidConfig['look']; + /** + * Stroke pattern, read by `insertEdge`. The note edge is the only state edge that sets + * it; see the note-edge push in `dataFetcher.ts` for why the CSS class is not enough. + */ + pattern?: 'solid' | 'dotted' | 'dashed'; } /** diff --git a/packages/mermaid/src/diagrams/state/stateNoteEdge.spec.ts b/packages/mermaid/src/diagrams/state/stateNoteEdge.spec.ts new file mode 100644 index 00000000000..a08edfc4c01 --- /dev/null +++ b/packages/mermaid/src/diagrams/state/stateNoteEdge.spec.ts @@ -0,0 +1,66 @@ +/** + * The edge joining a state to its note is dashed. That used to be expressed only as CSS -- + * `.note-edge { stroke-dasharray: 5 }` in `state/styles.js` -- which is enough under the + * `classic` look and not enough under `neo`. + * + * Under `neo`, `insertEdge` writes an *inline* `stroke-dasharray` on every edge, computed + * from the path length: for a solid edge, one long run trimmed at both ends so the arrow + * markers have their gaps. An inline style outranks a stylesheet rule, so the note edge + * came out solid with the `.note-edge` rule still present and simply losing. `neo` became + * the default look, which is what made a long-standing quirk everyone's problem. + * + * `insertEdge` chooses between the two dash generators on `edge.pattern`, so that is where + * the dashing has to be declared. This pins it there. + */ +// @ts-expect-error No types available for JISON +import stateDiagram, { parser } from './parser/stateDiagram.jison'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { StateDB } from './stateDb.js'; + +describe('state note edges', () => { + let stateDb: StateDB; + + beforeEach(() => { + stateDb = new StateDB(2); + parser.yy = stateDb; + stateDiagram.parser.yy = stateDb; + stateDiagram.parser.yy.clear(); + }); + + const edges = (diagram: string) => { + parser.parse(diagram); + return stateDb.getData().edges; + }; + + const diagram = `stateDiagram-v2 + [*] --> Active + Active --> Idle + note right of Active + A note + end note + `; + + it('declares the note edge as dashed on the edge, not only in CSS', () => { + const noteEdge = edges(diagram).find((edge) => edge.classes.includes('note-edge')); + expect(noteEdge).toBeDefined(); + // `insertEdge` reads this to pick its dash generator under the neo look. Without it the + // edge takes the solid generator and the inline result hides `.note-edge`. + expect(noteEdge?.pattern).toBe('dashed'); + }); + + it('leaves ordinary transitions solid', () => { + // The fix must not dash every edge: `pattern` is absent on transitions, which is what + // sends them down the solid branch. + const transitions = edges(diagram).filter((edge) => !edge.classes.includes('note-edge')); + expect(transitions.length).toBeGreaterThan(0); + expect(transitions.every((edge) => edge.pattern === undefined)).toBe(true); + }); + + it('keeps the note-edge class, which is what the classic look still styles', () => { + // `classic` writes no inline dasharray, so it is the `.note-edge` rule that dashes the + // line there -- and that rule beats `edge-pattern-dashed` on source order. Dropping the + // class in favour of the pattern alone would change the classic look's dash length. + const noteEdge = edges(diagram).find((edge) => edge.classes.includes('note-edge')); + expect(noteEdge?.classes).toContain('note-edge'); + }); +}); diff --git a/scripts/tsc-check.ts b/scripts/tsc-check.ts index 0cc9f7ab739..1000297bd52 100644 --- a/scripts/tsc-check.ts +++ b/scripts/tsc-check.ts @@ -8,10 +8,51 @@ import { execFileSync } from 'child_process'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { tmpdir } from 'node:os'; +import { createRequire } from 'node:module'; const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file const __dirname = path.dirname(__filename); // get the name of the directory +const require_ = createRequire(import.meta.url); + +/** + * `mermaid`'s emitted `.d.ts` files reference `type-fest` and `@types/d3` directly, so the + * throwaway project has to install them itself. Their versions are not a free choice: the + * check compiles the *published* declarations, and those were built against exactly the + * ranges `packages/mermaid/package.json` declares. + * + * They used to be written as `'*'` and a hardcoded `'^7.4.3'`, which quietly meant "whatever + * the registry serves today". That resolved `type-fest` to a major mermaid has never been + * compiled against and broke this job at random -- 5.9.0 added `Float16Array` to its + * `TypedArray` union, which does not exist under the `es2020` lib below, and `skipLibCheck` + * is deliberately off here so third-party declarations are checked too. Two runs of the same + * commit seconds apart disagreed, depending on what npm resolved. + * + * Reading the ranges from the package under test is what makes this reproducible. It is also + * what the note below the field always asked for. + */ +const MERMAID_PKG = require_('../packages/mermaid/package.json') as { + dependencies?: Record; + devDependencies?: Record; +}; + +/** + * Look in both sections: `type-fest` is a devDependency while `@types/d3` is a runtime one, + * and which section a type package sits in is not something this check should care about. + * Throw rather than emit `undefined` -- npm reads a missing range as "latest", which is the + * exact failure mode being fixed here, and it would come back silently. + */ +const typeDependency = (name: string): string => { + const range = MERMAID_PKG.dependencies?.[name] ?? MERMAID_PKG.devDependencies?.[name]; + if (!range) { + throw new Error( + `tsc-check: packages/mermaid/package.json no longer declares '${name}'. ` + + `Update scripts/tsc-check.ts to match wherever it moved.` + ); + } + return range; +}; + /** * Packages to build and import */ @@ -34,10 +75,15 @@ const SRC = { dependencies: tarballs, scripts: { build: 'tsc -b --verbose' }, devDependencies: { - // these are somewhat-unexpectedly required, and a downstream would need - // to match the real `package.json` values - 'type-fest': '*', - '@types/d3': '^7.4.3', + // these are somewhat-unexpectedly required, and a downstream needs to match the + // real `package.json` values -- see `typeDependency` + 'type-fest': typeDependency('type-fest'), + '@types/d3': typeDependency('@types/d3'), + // Deliberately unpinned: a downstream picks its own compiler, so checking against + // the current release is the signal this job exists to give. Pinning it to the + // repo's own TypeScript additionally needs `"type": "module"` here, or the + // generated `src/index.ts` is treated as CommonJS under `moduleResolution: + // nodenext` and cannot import these ESM-only packages (TS1479). typescript: '*', }, }, @@ -121,7 +167,9 @@ async function main() { for (const argv of COMMANDS) { console.warn('... in', cwd); console.warn('>>>', ...argv); - execFileSync(argv[0], argv.slice(1), { cwd }); + // `stdio: 'inherit'`, or a failure prints the compiler output as `` and + // the actual diagnostic never reaches the log. + execFileSync(argv[0], argv.slice(1), { cwd, stdio: 'inherit' }); } for (const lib of LIB) {