From 3cdaf09059d6e3629c2c292c9da7b8d72ba381f7 Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 26 Mar 2026 21:15:30 +0100 Subject: [PATCH 1/2] refactor: add tanstack signal tanstack plugin, reorder files (@miodec) (#7721) --- .../ts/components/{core => dev}/DevTools.tsx | 0 .../src/ts/components/dev/SignalsDevtools.tsx | 328 ++++++++++++++++++ .../{core => dev}/TanstackDevtools.tsx | 2 + frontend/src/ts/components/mount.tsx | 2 +- frontend/src/ts/dev/signal-tracker.ts | 162 +++++++++ frontend/src/ts/event-handlers/global.ts | 10 + frontend/src/ts/index.ts | 3 + 7 files changed, 506 insertions(+), 1 deletion(-) rename frontend/src/ts/components/{core => dev}/DevTools.tsx (100%) create mode 100644 frontend/src/ts/components/dev/SignalsDevtools.tsx rename frontend/src/ts/components/{core => dev}/TanstackDevtools.tsx (88%) create mode 100644 frontend/src/ts/dev/signal-tracker.ts diff --git a/frontend/src/ts/components/core/DevTools.tsx b/frontend/src/ts/components/dev/DevTools.tsx similarity index 100% rename from frontend/src/ts/components/core/DevTools.tsx rename to frontend/src/ts/components/dev/DevTools.tsx diff --git a/frontend/src/ts/components/dev/SignalsDevtools.tsx b/frontend/src/ts/components/dev/SignalsDevtools.tsx new file mode 100644 index 000000000000..e774948e63d5 --- /dev/null +++ b/frontend/src/ts/components/dev/SignalsDevtools.tsx @@ -0,0 +1,328 @@ +import type { TanStackDevtoolsSolidPlugin } from "@tanstack/solid-devtools"; + +import { + createEffect, + createSignal, + For, + JSXElement, + on, + onMount, + Show, +} from "solid-js"; + +import { trackedSignals, type TrackedSignal } from "../../dev/signal-tracker"; +import { useRef } from "../../hooks/useRef"; +import { cn } from "../../utils/cn"; +import { Balloon } from "../common/Balloon"; + +type SignalGroup = { file: string; signals: TrackedSignal[] }; + +function buildGroups(): SignalGroup[] { + const groupMap = new Map(); + + for (const s of trackedSignals) { + // extract filename from source path (e.g. "/ts/states/core.ts:4:44" -> "states/core.ts") + const match = /\/ts\/(.+?)(?::\d+)*(?:\)?)$/.exec(s.source); + const group = match?.[1] ?? (s.source !== "" ? s.source : s.owner); + const entries = groupMap.get(group) ?? []; + entries.push(s); + groupMap.set(group, entries); + } + + return Array.from(groupMap.entries()).map(([file, signals]) => ({ + file, + signals, + })); +} + +function formatValue(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return `${value}`; + } + try { + return JSON.stringify(value); + } catch { + return `[${typeof value}]`; + } +} + +function parseValue(input: string): unknown { + const trimmed = input.trim(); + if (trimmed === "null") return null; + if (trimmed === "undefined") return undefined; + if (trimmed === "true") return true; + if (trimmed === "false") return false; + const num = Number(trimmed); + if (trimmed !== "" && !Number.isNaN(num)) return num; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return trimmed; + } +} + +function SignalRow(props: { signal: TrackedSignal }): JSXElement { + const [flashing, setFlashing] = createSignal(false); + const [editing, setEditing] = createSignal(false); + const [editValue, setEditValue] = createSignal(""); + let initialized = false; + + createEffect( + on( + () => formatValue(props.signal.get()), + () => { + if (!initialized) { + initialized = true; + return; + } + setFlashing(true); + setTimeout(() => setFlashing(false), 125); + }, + ), + ); + + const startEditing = (): void => { + setEditValue(formatValue(props.signal.get())); + setEditing(true); + }; + + const commitEdit = (): void => { + props.signal.set(parseValue(editValue())); + setEditing(false); + }; + + const cancelEdit = (): void => { + setEditing(false); + }; + + return ( + + +
+ {props.signal.name} + + + ? + + +
+ + +
+ +
+ + + } + > + + +
+
+ + + { + const current = props.signal.get(); + if (typeof current === "boolean") { + props.signal.set(!current); + } else { + startEditing(); + } + }} + > + {formatValue(props.signal.get())} + + } + > + { + e.preventDefault(); + e.stopImmediatePropagation(); + setEditValue(e.currentTarget.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") commitEdit(); + if (e.key === "Escape") cancelEdit(); + }} + ref={(el) => setTimeout(() => el.focus())} + class="w-full rounded border px-1 py-0.5 text-xs text-text outline-none focus:border-main" + data-ui-element="signalDevtoolsInput" + style={{ + "background-color": "#313749", + "border-color": "#414962", + }} + /> + + + {/* + + */} + + ); +} + +function SignalGroupSection(props: { group: SignalGroup }): JSXElement { + const [collapsed, setCollapsed] = createSignal(false); + + return ( +
+ + + + + + {(signal) => } + + +
+
+
+ ); +} + +function SignalsPanel(): JSXElement { + const [search, setSearch] = createSignal(""); + const groups = buildGroups(); + const [ref, el] = useRef(); + + const filteredGroups = (): SignalGroup[] => { + const query = search().toLowerCase(); + if (query === "") return groups; + return groups + .map((group) => { + if (group.file.toLowerCase().includes(query)) return group; + const filtered = group.signals.filter((s) => + s.name.toLowerCase().includes(query), + ); + return { file: group.file, signals: filtered }; + }) + .filter((group) => group.signals.length > 0); + }; + + onMount(() => { + if (el()) { + el()?.parentElement?.style.setProperty("height", "100%"); + el()?.parentElement?.style.setProperty("overflow", "scroll"); + } + }); + + return ( +
+
+ { + e.preventDefault(); + e.stopImmediatePropagation(); + setSearch(e.currentTarget.value); + }} + class="mb-3 w-full rounded border border-sub bg-bg px-2 py-1 text-xs text-text outline-none placeholder:text-[#6f748d] focus:border-main" + data-ui-element="signalDevtoolsInput" + style={{ + "background-color": "#313749", + "border-color": "#414962", + }} + /> +
+
+ + {(group) => } + +
+
+ ); +} + +export function SignalsDevtoolsPlugin(): TanStackDevtoolsSolidPlugin { + return { + id: "core-signals", + name: "Signals", + render: () => , + }; +} diff --git a/frontend/src/ts/components/core/TanstackDevtools.tsx b/frontend/src/ts/components/dev/TanstackDevtools.tsx similarity index 88% rename from frontend/src/ts/components/core/TanstackDevtools.tsx rename to frontend/src/ts/components/dev/TanstackDevtools.tsx index 3f906ea91313..6e31b0559d23 100644 --- a/frontend/src/ts/components/core/TanstackDevtools.tsx +++ b/frontend/src/ts/components/dev/TanstackDevtools.tsx @@ -4,6 +4,7 @@ import { SolidQueryDevtoolsPanel } from "@tanstack/solid-query-devtools"; import { JSXElement } from "solid-js"; import { queryClient } from "../../queries"; +import { SignalsDevtoolsPlugin } from "./SignalsDevtools"; export function TanStackDevtools(): JSXElement { return ( @@ -16,6 +17,7 @@ export function TanStackDevtools(): JSXElement { defaultOpen: true, }, hotkeysDevtoolsPlugin(), + SignalsDevtoolsPlugin(), ]} config={{ defaultOpen: false }} /> diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index 9fbcf78c5eb3..432c8c884f28 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -4,8 +4,8 @@ import { render } from "solid-js/web"; import { queryClient } from "../queries"; import { qsa } from "../utils/dom"; -import { DevTools } from "./core/DevTools"; import { Theme } from "./core/Theme"; +import { DevTools } from "./dev/DevTools"; import { CommandlineHotkey } from "./hotkeys/CommandlineHotkey"; import { Footer } from "./layout/footer/Footer"; import { Header } from "./layout/header/Header"; diff --git a/frontend/src/ts/dev/signal-tracker.ts b/frontend/src/ts/dev/signal-tracker.ts new file mode 100644 index 000000000000..f495e559a9a4 --- /dev/null +++ b/frontend/src/ts/dev/signal-tracker.ts @@ -0,0 +1,162 @@ +import { createSignal, DEV } from "solid-js"; + +export type TrackedSignal = { + name: string; + type: "signal" | "store"; + owner: string; + ownerChain: string; + source: string; + initialValue: string; + get: () => unknown; + set: (v: unknown) => void; + getObserverCount: () => number; +}; + +export const trackedSignals: TrackedSignal[] = []; + +/** Internal Solid signal/store node shape (subset used by devtools) */ +type SolidNode = { + name?: string; + value?: unknown; + observers?: unknown[]; + fn?: unknown; + graph?: SolidOwner; +}; + +type SolidOwner = { + name?: string; + component?: { name?: string }; + owner?: SolidOwner | null; +}; + +function getCallerInfo(): { isUserCode: boolean; source: string } { + const stack = new Error().stack; + if (stack === undefined) return { isUserCode: false, source: "" }; + const frames = stack.split("\n").slice(1); + + if (frames.some((f) => f.includes("useRefWithUtils"))) { + return { isUserCode: false, source: "" }; + } + + for (const frame of frames.toReversed()) { + if (frame.includes("signal-tracker")) continue; + if (frame.includes("solid-js")) continue; + if (frame.includes("@solid-refresh")) continue; + const isUserCode = !frame.includes("node_modules"); + const urlMatch = /https?:\/\/[^/]+(\/[^?)]+)(?:\?[^:]*)?(:[\d:]+)/.exec( + frame, + ); + const source = + urlMatch !== null ? `${urlMatch[1]}${urlMatch[2]}` : frame.trim(); + if (source.includes("AnimePresence")) { + console.log(source, frames); + } + return { isUserCode, source }; + } + return { isUserCode: false, source: "" }; +} + +function getOwnerChain(node: SolidNode): string { + const names: string[] = []; + let owner: SolidOwner | undefined | null = node.graph; + while (owner !== undefined && owner !== null) { + const ownerName = owner.name ?? owner.component?.name; + if (ownerName !== undefined) names.push(ownerName); + owner = owner.owner; + } + return names.join(" > "); +} + +function getOwnerName(node: SolidNode): string { + return node.graph?.name ?? node.graph?.component?.name ?? "unknown"; +} + +function getNodeName(node: SolidNode): string | undefined { + return node.name ?? node.graph?.name ?? node.graph?.component?.name; +} + +function formatInitialValue(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return `"${value}"`; + if (typeof value === "number" || typeof value === "boolean") { + return `${value}`; + } + try { + const str = JSON.stringify(value); + return str.length > 80 ? `${str.slice(0, 80)}...` : str; + } catch { + return `[${typeof value}]`; + } +} + +if (DEV) { + type NodeInfo = { + name: string; + type: "signal" | "store"; + source: string; + ownerChain: string; + initialValue: string; + }; + const pendingNodes = new Map(); + + const writeSignal = DEV.writeSignal as ( + node: SolidNode, + value: unknown, + ) => unknown; + + DEV.hooks.afterRegisterGraph = (rawNode: object) => { + const node = rawNode as SolidNode; + const isSignal = "observers" in node; + const isStore = !isSignal && "value" in node && !("fn" in node); + if (!isSignal && !isStore) return; + + // cheap check: must have a name + const name = getNodeName(node); + if (name === undefined) return; + + // expensive check: stack trace for user code filtering + const { isUserCode, source } = getCallerInfo(); + if (!isUserCode) return; + + pendingNodes.set(node, { + name, + type: isSignal ? "signal" : "store", + source, + ownerChain: getOwnerChain(node), + initialValue: formatInitialValue(node.value), + }); + }; + + queueMicrotask(() => { + const mirrors: { node: SolidNode; set: (v: unknown) => void }[] = []; + + for (const [node, info] of pendingNodes) { + const [get, set] = createSignal(node.value); + + trackedSignals.push({ + name: info.name, + type: info.type, + owner: getOwnerName(node), + ownerChain: info.ownerChain, + source: info.source, + initialValue: info.initialValue, + get, + set: (v: unknown) => writeSignal(node, v), + getObserverCount: () => node.observers?.length ?? 0, + }); + + mirrors.push({ node, set }); + } + pendingNodes.clear(); + + // Sync node values to reactive mirrors, only when tab is visible + const syncLoop = (): void => { + for (const mirror of mirrors) { + mirror.set(() => mirror.node.value); + } + requestAnimationFrame(syncLoop); + }; + requestAnimationFrame(syncLoop); + }); +} diff --git a/frontend/src/ts/event-handlers/global.ts b/frontend/src/ts/event-handlers/global.ts index a4bec8b27c3f..907e6be695b0 100644 --- a/frontend/src/ts/event-handlers/global.ts +++ b/frontend/src/ts/event-handlers/global.ts @@ -13,6 +13,16 @@ document.addEventListener("keydown", (e) => { if (PageTransition.get()) return; if (e.key === undefined) return; + if (isDevEnvironment()) { + if ( + (document.activeElement as HTMLElement | undefined)?.dataset[ + "uiElement" + ] === "signalDevtoolsInput" + ) { + return; + } + } + const pageTestActive: boolean = getActivePage() === "test"; if (pageTestActive && !TestState.resultVisible && !isInputElementFocused()) { const popupVisible: boolean = Misc.isAnyPopupVisible(); diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index d6b4a96dbe5a..7cc56fdd05f4 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -1,3 +1,6 @@ +// register signal tracking hook before any signals are created +import "./dev/signal-tracker"; + //enable solidjs-devtools import "solid-devtools"; From 3bba57b77b25b63ea9a0af00ec1b85536ef0851a Mon Sep 17 00:00:00 2001 From: Leonabcd123 <156839416+Leonabcd123@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:16:32 +0200 Subject: [PATCH 2/2] fix(carryover): only reset keypress timings on restart and only carryover on start (@Leonabcd123, @miodec) (#7705) Whoops. --- frontend/src/ts/test/test-input.ts | 8 +++----- frontend/src/ts/test/test-logic.ts | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/src/ts/test/test-input.ts b/frontend/src/ts/test/test-input.ts index 1dfa8cf72d82..c58e088a75d9 100644 --- a/frontend/src/ts/test/test-input.ts +++ b/frontend/src/ts/test/test-input.ts @@ -452,7 +452,7 @@ function updateOverlap(now: number): void { } } -function carryoverFirstKeypress(): void { +export function carryoverFirstKeypress(): void { // Because keydown triggers before input, we need to grab the first keypress data here and carry it over // Take the key with the largest index @@ -487,7 +487,7 @@ function carryoverFirstKeypress(): void { } } -export function resetKeypressTimings(carryover: boolean): void { +function resetKeypressTimings(): void { keypressTimings = { spacing: { first: -1, @@ -505,8 +505,6 @@ export function resetKeypressTimings(carryover: boolean): void { keyDownData = {}; noCodeIndex = 0; - if (carryover) carryoverFirstKeypress(); - console.debug("Keypress timings reset"); } @@ -555,5 +553,5 @@ export function restart(): void { incorrect: 0, }; - resetKeypressTimings(false); + resetKeypressTimings(); } diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 8d0ecd95d48e..4fc8fac60eab 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -145,7 +145,7 @@ export function startTest(now: number): boolean { TestState.setActive(true); Replay.startReplayRecording(); Replay.replayGetWordsList(TestWords.words.list); - TestInput.resetKeypressTimings(true); + TestInput.carryoverFirstKeypress(); Time.set(0); TestTimer.clear();