Skip to content

Commit 82c6205

Browse files
committed
Drop unused MDX deps and inline use-scramble
First-principles audit of the runtime dep list. Four packages removed: - react-markdown: zero imports anywhere in the codebase. Velite/MDX is the actual blog pipeline; this was dead weight. - @next/mdx: not wired into next.config.mjs (no withMDX() wrapper) and no .mdx routes under app/. Velite compiles MDX to JSX code that MDXRenderer.tsx evaluates against react/jsx-runtime. - @mdx-js/react: zero direct imports. Typically a peer of @next/mdx's React provider system, which isn't used here. velite depends on @mdx-js/mdx (the compiler), not @mdx-js/react (the runtime). - use-scramble: used in exactly one component (custom Link wrapper). Inlined as a ~50-line rAF loop with an in-flight guard and a callback ref. Behavior parity preserved: left-to-right reveal, 4-char scramble window, fires on hover/touch, whitespace passes through unscrambled. Switched from onMouseOver to onMouseEnter because mouseOver re-fires every time textContent mutates (browsers emit it on child-node changes), which would restart the animation in an infinite loop. The mdast-util-to-hast override stays — its two remaining transitive paths (shiki/rehype-pretty-code and velite/@mdx-js/mdx) still resolve to a pre-fix version without the override. CLAUDE.md updated to drop the now-removed react-markdown path from the rationale. bun audit, lint, typecheck, build, verify (17/17), and Playwright (18/18 including a new footer-scramble regression test that guards against the mouseover-textContent-refire loop) all pass. Signed-off-by: Will Manning <will@willmanning.io>
1 parent 1114b9b commit 82c6205

5 files changed

Lines changed: 86 additions & 31 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Defense in depth against the npm-worm class (Shai-Hulud, mini-Shai-Hulud, the Ma
6969
`bun audit` is the source of truth for dependency advisories. State as of 2026-05-04:
7070

7171
- **postcss `<8.5.10`** (GHSA-qx2v-qp2m-jg93, moderate XSS in CSS stringify). Multiple transitive resolutions — `next@16.2.4` pins `postcss@8.4.31` exactly, and `@tailwindcss/postcss@4.2.3` brings in `postcss@^8.5.6`. Resolved via `overrides.postcss = "8.5.10"` in `package.json`, which dedupes all transitives to the patched version. Drop the override after `next` and `@tailwindcss/postcss` ship releases that pull their transitives to ≥ 8.5.10.
72-
- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by three independent paths (shiki/rehype-pretty-code, react-markdown, velite/@mdx-js/mdx) — all parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1.
72+
- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by two independent paths (shiki/rehype-pretty-code, velite/@mdx-js/mdx) — both parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1.
7373
- **uuid `<14.0.0`** (GHSA-w5hq-g745-h8pq, moderate missing buffer bounds in v3/v5/v6 when `buf` provided). **Upstream-blocked.** Two parent paths: `resend@6.12.2 → svix@1.90.0 → uuid@^10.0.0` and `@lhci/cli@0.15.1 → uuid@8.3.2`. Neither parent admits a 14.x override without risking CJS imports. Exposure is theoretical on both: `/api/subscribe` uses Resend's send-email endpoint (not svix's webhook-signing path), `@lhci/cli` is dev-only and runs in CI on its own controlled inputs, and the vulnerable code (v3/v5/v6 with explicit `buf`) isn't called by either. Remove the `--ignore` when both parents ship releases bumping uuid to `^14.0.0`.
7474
- **tmp `<=0.2.3`** (GHSA-52f5-9888-hmc6, low symbolic-link path traversal in `dir` param). **Upstream-blocked.** Pulled exclusively by `@lhci/cli@0.15.1` (dev-only, runs in CI on controlled inputs). The symlink-traversal scenario doesn't apply. Remove the `--ignore` when `@lhci/cli` ships a release with patched transitives.
7575

bun.lock

Lines changed: 0 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
"test:e2e:install": "playwright install --with-deps chromium webkit"
2424
},
2525
"dependencies": {
26-
"@mdx-js/react": "^3.1.1",
27-
"@next/mdx": "^16.2.4",
2826
"@tailwindcss/typography": "^0.5.19",
2927
"@vercel/analytics": "^2.0.1",
3028
"feed": "^5.2.1",
@@ -33,10 +31,8 @@
3331
"ogl": "^1.0.11",
3432
"react": "^19.2.5",
3533
"react-dom": "^19.2.5",
36-
"react-markdown": "^10.1.0",
3734
"rehype-pretty-code": "^0.14.3",
3835
"resend": "^6.12.2",
39-
"use-scramble": "^2.2.15",
4036
"velite": "^0.3.1"
4137
},
4238
"devDependencies": {

src/components/link/index.tsx

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,71 @@
11
"use client";
22

33
import NextLink from "next/link";
4-
import { useScramble } from "use-scramble";
4+
import { useCallback, useEffect, useRef } from "react";
55

66
interface LinkProps extends React.ComponentProps<typeof NextLink> {
77
children: string;
88
}
99

10+
const SCRAMBLE_CHARS =
11+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
12+
const SCRAMBLE_WINDOW = 4;
13+
const FRAME_MS = 30;
14+
1015
export const Link = ({ href, children, ...props }: LinkProps) => {
11-
const { ref, replay } = useScramble({
12-
text: children,
13-
speed: 0.5,
14-
tick: 1,
15-
step: 1,
16-
scramble: 4,
17-
seed: 0,
18-
playOnMount: false
19-
});
16+
const nodeRef = useRef<HTMLAnchorElement | null>(null);
17+
const frameRef = useRef<number | null>(null);
18+
19+
const setNode = useCallback((node: HTMLAnchorElement | null) => {
20+
nodeRef.current = node;
21+
}, []);
22+
23+
useEffect(
24+
() => () => {
25+
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
26+
},
27+
[]
28+
);
29+
30+
const replay = useCallback(() => {
31+
const el = nodeRef.current;
32+
if (!el) return;
33+
if (frameRef.current !== null) return;
34+
const target = children;
35+
let revealed = 0;
36+
let lastTime = 0;
37+
const tick = (now: number) => {
38+
if (now - lastTime >= FRAME_MS) {
39+
lastTime = now;
40+
if (revealed >= target.length) {
41+
el.textContent = target;
42+
frameRef.current = null;
43+
return;
44+
}
45+
const head = target.slice(0, revealed);
46+
let tail = "";
47+
const tailLen = Math.min(SCRAMBLE_WINDOW, target.length - revealed);
48+
for (let i = 0; i < tailLen; i++) {
49+
const ch = target.charAt(revealed + i);
50+
tail += /\s/.test(ch)
51+
? ch
52+
: SCRAMBLE_CHARS.charAt(
53+
(Math.random() * SCRAMBLE_CHARS.length) | 0
54+
);
55+
}
56+
el.textContent = head + tail;
57+
revealed += 1;
58+
}
59+
frameRef.current = requestAnimationFrame(tick);
60+
};
61+
frameRef.current = requestAnimationFrame(tick);
62+
}, [children]);
2063

2164
return (
2265
<NextLink
2366
href={href}
24-
ref={ref}
25-
onMouseOver={replay}
67+
ref={setNode}
68+
onMouseEnter={replay}
2669
onTouchStart={replay}
2770
{...props}
2871
>

tests/smoke.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,36 @@ test.describe("blog post", () => {
9999
});
100100
});
101101

102+
test.describe("footer", () => {
103+
test("custom Link scramble settles back to original on hover", async ({
104+
page
105+
}) => {
106+
await page.goto("/");
107+
const link = page.locator('a[href="https://lfprojects.org"]').first();
108+
await expect(link).toBeVisible();
109+
const original = (await link.textContent()) ?? "";
110+
111+
// Trigger a real pointer enter — the scramble fires on hover.
112+
await link.hover();
113+
114+
// Sample intermediate frames: the in-flight animation must produce at
115+
// least one text snapshot that differs from the static text. Without
116+
// the in-flight guard in the Link component, the scramble would loop
117+
// forever because mutating textContent re-fires mouseover.
118+
const observed = new Set<string>();
119+
for (let i = 0; i < 30; i++) {
120+
observed.add((await link.textContent()) ?? "");
121+
await page.waitForTimeout(20);
122+
}
123+
124+
// Wait for the animation to settle, then assert it returned to the
125+
// original string (regression check for the textContent-refire loop).
126+
await page.waitForTimeout(800);
127+
expect(await link.textContent()).toBe(original);
128+
expect([...observed].some((s) => s !== original)).toBe(true);
129+
});
130+
});
131+
102132
test.describe("mobile layout", () => {
103133
test.use({ viewport: { width: 375, height: 812 } });
104134

0 commit comments

Comments
 (0)