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
7 changes: 7 additions & 0 deletions .changeset/state-note-edge-neo-dashes.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/mermaid/src/diagrams/state/dataFetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/mermaid/src/diagrams/state/stateDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
66 changes: 66 additions & 0 deletions packages/mermaid/src/diagrams/state/stateNoteEdge.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
58 changes: 53 additions & 5 deletions scripts/tsc-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
devDependencies?: Record<string, string>;
};

/**
* 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
*/
Expand All @@ -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: '*',
},
},
Expand Down Expand Up @@ -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 `<Buffer 0a 3e ...>` and
// the actual diagnostic never reaches the log.
execFileSync(argv[0], argv.slice(1), { cwd, stdio: 'inherit' });
}

for (const lib of LIB) {
Expand Down
Loading