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
4 changes: 2 additions & 2 deletions .changeset/rc12-runtime-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
'@solidjs/start-devtools': patch
---

Build and read the Solid 2.0.0-rc.12 runtime.
Build and read the Solid 2.0.0-rc.13 runtime.

The toolbar is compiled with `@solidjs/compiler`, the compiler that ships with the runtime, so delegated event handlers use the key the runtime reads (`_$$click`). Every click in the reactivity and ownership panels was dead against rc.9 and later.
The `solid-js` and `@solidjs/web` peers move to `^2.0.0-rc.12`.
The `solid-js` and `@solidjs/web` peers move to `^2.0.0-rc.13`.
The reactivity graph reads a node's error off its extension (`_x._error`), recognises store slot nodes by the store they belong to, and hides the memo the built-in hot reload wrapper creates, which the runtime now marks as plumbing instead of naming. The ownership tree walks through that memo without a row.
A server render that fails sets the 500 status and logs, without writing toolbar state.
9 changes: 9 additions & 0 deletions .changeset/twinkleplop-highlighting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@solidjs/start-devtools': patch
---

Highlight error sources with twinkleplop instead of shiki.

The code view now highlights synchronously, so a stack frame's source appears with the panel rather than a moment later, and the toolbar no longer ships a WebAssembly grammar engine. The built package drops from 8.1 MB to 3.6 MB, and the error viewer chunk from 1.1 MB to 273 kB.
The panel highlights the whole file and keeps twenty-five lines on each side of the frame, which is about as far as the view scrolls. Cutting the file before highlighting it left the highlighter reading a block comment or template literal it never saw open, and the colours fell apart from there. The view opens on the frame's line.
The frame's line is marked with the `focus` directive and the word it points at with `err`, both written as comments above the file. The code itself is never edited to carry them, and marker lines are dropped from the output.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
"@solidjs/compiler": "2.0.0-rc.13",
"@solidjs/vite-plugin": "3.0.0-next.46",
"@solidjs/web": "^2.0.0-rc.13",
"@twinkleplop/annotation": "^0.1.5",
"@twinkleplop/theme-github": "^0.2.2",
"@twinkleplop/tsx": "^0.1.5",
"@twinkleplop/typescript": "^0.1.5",
"@types/node": "^24.0.0",
"error-stack-parser-es": "^2.0.1",
"html-to-image": "^1.11.13",
Expand All @@ -71,7 +75,6 @@
"rolldown": "^1.2.4",
"rolldown-plugin-dts": "^0.28.2",
"seroval": "^1.6.0",
"shiki": "^4.3.1",
"solid-js": "^2.0.0-rc.13",
"terracotta": "2.0.0-next.9",
"typescript": "^7.0.2",
Expand Down
410 changes: 73 additions & 337 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

24 changes: 1 addition & 23 deletions rolldown.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,6 @@ function css(server = false) {
};
}

function assetUrl() {
return {
name: 'devtools-asset-url',
async resolveId(source, importer) {
if (!source.endsWith('?url')) return null;
const resolved = await this.resolve(source.slice(0, -4), importer, {
skipSelf: true,
});
return resolved ? `${resolved.id}?url` : null;
},
load(id) {
if (!id.endsWith('?url')) return null;
const referenceId = this.emitFile({
type: 'asset',
name: 'onig.wasm',
source: readFileSync(id.slice(0, -4)),
});
return `export default import.meta.ROLLDOWN_FILE_URL_${referenceId};`;
},
};
}

function packageVersion() {
const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url))).version;
return {
Expand Down Expand Up @@ -112,7 +90,7 @@ function config({ input, entryFileNames, chunkFileNames, generate, server = fals
sourcemap: true,
},
external,
plugins: [css(server), assetUrl(), packageVersion(), solid(generate)],
plugins: [css(server), packageVersion(), solid(generate)],
};
}

Expand Down
112 changes: 32 additions & 80 deletions src/dev-toolbar/error-viewer/CodeView.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,49 @@
// @refresh skip
import { getSingletonHighlighterCore, type HighlighterCore } from 'shiki/core';
import { createOnigurumaEngine } from 'shiki/engine/oniguruma';
import langJS from 'shiki/langs/javascript.mjs';
import langJSX from 'shiki/langs/jsx.mjs';
import langTSX from 'shiki/langs/tsx.mjs';
import langTS from 'shiki/langs/typescript.mjs';
import url from 'shiki/onig.wasm?url';
import darkPlus from 'shiki/themes/dark-plus.mjs';
import { createEffect, createMemo } from 'solid-js';
import { createEffect, createMemo, createSignal } from 'solid-js';
import type { JSX } from '@solidjs/web';

let HIGHLIGHTER: HighlighterCore;

async function loadHighlighter() {
if (!HIGHLIGHTER) {
HIGHLIGHTER = await getSingletonHighlighterCore({
engine: createOnigurumaEngine(fetch(url)),
themes: [darkPlus],
langs: [langJS, langJSX, langTS, langTSX],
});
}
return HIGHLIGHTER;
}
import { codeToHtml } from './highlight.js';
import '@twinkleplop/theme-github/dark';

export interface CodeViewProps {
fileName: string;
content: string;
line: number;
/** Column of the error, so the marker can point at one word instead of the line. */
column?: number;
}

const RANGE = 15;
/**
* Lines kept on each side of the frame.
*
* The view scrolls about this far, so the snippet holds what the reader can
* scroll to and nothing beyond it.
*/
const RANGE = 25;

export function CodeView(props: CodeViewProps): JSX.Element | null {
const lines = () =>
props.content.split('\n').map((item, index) => ({
index: index + 1,
line: item,
}));

const minLine = () => Math.max(props.line - (1 + RANGE), 0);
const maxLine = () => Math.min(props.line + RANGE, lines().length - 1);

let ref: HTMLDivElement | undefined;

const data = createMemo(async () => {
const value = lines()
.slice(minLine(), maxLine())
.map((item) => item.line)
.join('\n');
const highlighter = await loadHighlighter();
const fileExtension = props.fileName.split(/[#?]/)[0]!.split('.').pop()?.trim();
// Only these grammars are loaded — anything else would make shiki
// throw. Fall back to plain JS highlighting for unknown sources.
let lang: 'js' | 'jsx' | 'ts' | 'tsx' = 'js';
if (
fileExtension === 'jsx' ||
fileExtension === 'ts' ||
fileExtension === 'tsx' ||
fileExtension === 'js'
) {
lang = fileExtension;
}
return highlighter.codeToHtml(value, {
theme: 'dark-plus',
lang,
});
});
const [element, setElement] = createSignal<HTMLDivElement>();

const html = createMemo(() =>
codeToHtml({
fileName: props.fileName,
content: props.content,
line: props.line,
column: props.column,
range: RANGE,
}),
);

// The frame can sit anywhere in the file, so the view opens on it. Scrolling
// the box itself leaves the page where it is.
createEffect(
() => data(),
(result) => {
if (ref && result) {
ref.innerHTML = result;

const lines = ref.querySelectorAll('span[class="line"]');

for (let i = 0, len = lines.length; i < len; i++) {
const el = lines[i] as HTMLElement;
if (props.line - minLine() - 1 === i) {
el.dataset.solidErrorViewerErrorLine = '';
}
}
}
() => ({ view: element(), code: html() }),
({ view }) => {
if (!view) return;
const focused = view.querySelector('.focus') as HTMLElement | null;
if (!focused) return;
view.scrollTop = Math.max(focused.offsetTop - view.clientHeight / 2, 0);
},
);

return (
<div
ref={ref}
data-solid-error-viewer-code-view
style={{
'--error-viewer-code-view-start': minLine() + 1,
}}
/>
);
return <div ref={setElement} data-solid-error-viewer-code-view innerHTML={html()} />;
}
4 changes: 0 additions & 4 deletions src/dev-toolbar/error-viewer/env.d.ts

This file was deleted.

68 changes: 68 additions & 0 deletions src/dev-toolbar/error-viewer/highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { err, focus } from '@twinkleplop/annotation';
import { language as tsxLanguage } from '@twinkleplop/tsx';
import { language as typescriptLanguage } from '@twinkleplop/typescript';
import { buildSnippet, type SnippetInput } from './marker.js';

const options = {
annotation: {
plugins: [focus, err],
// The markers are written by the panel, not by the reader, so a bad one is
// a bug here rather than something the app's console should carry.
on_error: () => {},
},
};

const highlighters = {
ts: typescriptLanguage(options),
tsx: tsxLanguage(options),
};

/** The grammar for a file. The TSX grammar also covers plain JavaScript and JSX. */
function highlighterFor(fileName: string) {
const extension = fileName.split(/[#?]/)[0]!.split('.').pop()?.trim();
return extension === 'ts' || extension === 'mts' || extension === 'cts'
? highlighters.ts
: highlighters.tsx;
}

const OUTSIDE = 'data-outside';

/**
* Removes the lines the window leaves out.
*
* The highlighter writes one row per line and joins them with newlines, and a
* row never holds a newline of its own, so the rows split cleanly. The wrapper
* around them is kept whatever the first shown line is.
*/
function dropOutsideRows(html: string): string {
const open = html.indexOf('<code>');
const close = html.lastIndexOf('</code>');
if (open < 0 || close < 0) return html;

const start = open + '<code>'.length;
const rows = html
.slice(start, close)
.split('\n')
.filter((row) => !row.includes(OUTSIDE));

return html.slice(0, start) + rows.join('\n') + html.slice(close);
}

export interface CodeHtmlInput extends SnippetInput {
fileName: string;
}

/** The window around a frame, highlighted as part of the whole file. */
export function codeToHtml(input: CodeHtmlInput): string {
const { source, offset, first, last } = buildSnippet(input);

const html = highlighterFor(input.fileName)(source, {
line_numbers: { start: 1 },
line: (_rendered: number, sourceLine: number) => {
const line = sourceLine - offset;
return line < first || line > last ? { attrs: { [OUTSIDE]: '' } } : undefined;
},
});

return dropOutsideRows(html);
}
1 change: 1 addition & 0 deletions src/dev-toolbar/error-viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ function StackFramesContent(props: StackFramesContentProps) {
<CodeView
fileName={source.source}
line={source.line}
column={source.column}
content={source.content}
/>
</div>
Expand Down
108 changes: 108 additions & 0 deletions src/dev-toolbar/error-viewer/marker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { codeToHtml } from './highlight.js';
import { buildSnippet, wordAt } from './marker.js';

describe('wordAt', () => {
const line = ' throw new Error("boom");';

it('reads a 1-based column', () => {
expect(wordAt(line, 3)).toBe('throw');
});

it('reads a 0-based column', () => {
expect(wordAt(line, 8)).toBe('new');
});

it('keeps the whole word from any position inside it', () => {
expect(wordAt(line, 13)).toBe('Error');
});

it('has no word for punctuation', () => {
expect(wordAt(line, 26)).toBeUndefined();
});

// A digit is part of a number, and a number reads worse marked than its line.
it('has no word for a number', () => {
expect(wordAt('const total = 42;', 15)).toBeUndefined();
});

it('has no word without a column', () => {
expect(wordAt(line, undefined)).toBeUndefined();
});
});

const FILE = [
'/**',
' * A block comment the window would cut in half.',
' * More of it.',
' */',
'export function boom() {',
' throw new Error("bang");',
'}',
'',
'const tail = 1;',
].join('\n');

describe('buildSnippet', () => {
// Both markers are dropped from the output, and `:N` counts dropped lines.
it('marks the line and the word the column points at', () => {
const { source } = buildSnippet({ content: FILE, line: 6, column: 3, range: 1 });
expect(source.split('\n').slice(0, 2)).toEqual(['// [!focus :8]', '// [!err =throw :8]']);
});

it('marks the line alone without a column', () => {
const { source } = buildSnippet({ content: FILE, line: 6, range: 1 });
expect(source.split('\n')[0]).toBe('// [!focus :7]');
});

it('keeps the whole file and names the window around the frame', () => {
const { source, first, last, offset } = buildSnippet({
content: FILE,
line: 6,
column: 3,
range: 1,
});

expect(source).toContain('A block comment the window would cut in half.');
expect({ first, last, offset }).toEqual({ first: 5, last: 7, offset: 2 });
});

it('stops the window at the ends of the file', () => {
const { first, last } = buildSnippet({ content: FILE, line: 5, range: 50 });
expect({ first, last }).toEqual({ first: 1, last: 9 });
});
});

describe('codeToHtml', () => {
// Cutting the file at the window would leave the highlighter inside a block
// comment it never saw open, and every line after it would read as comment.
it('highlights a window under an unclosed-looking comment', () => {
const html = codeToHtml({
fileName: 'boom.ts',
content: FILE,
line: 6,
column: 3,
range: 1,
});

expect(html).toContain('<span class="tok keyword">throw</span>');
expect(html).not.toContain('A block comment');
});

// The numbers are the file's own, and only the window's lines are kept.
it('keeps the window and numbers it by the file', () => {
const html = codeToHtml({ fileName: 'boom.ts', content: FILE, line: 6, column: 3, range: 1 });
const numbers = [...html.matchAll(/<span class="ln">(\d+)<\/span>/g)].map((match) =>
Number(match[1]),
);

expect(numbers).toEqual([5, 6, 7]);
});

it('puts the frame in focus and marks its word', () => {
const html = codeToHtml({ fileName: 'boom.ts', content: FILE, line: 6, column: 3, range: 1 });

expect(html).toContain('class="l focus"');
expect(html).toContain('<span class="tok error">');
});
});
Loading
Loading