Skip to content

Commit 585adac

Browse files
authored
Merge pull request #4 from scoobynko/chore/internal-polish
chore: harden internals without changing public behavior
2 parents 10b0509 + b789b49 commit 585adac

3 files changed

Lines changed: 91 additions & 72 deletions

File tree

.changeset/polish-internals.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@scoobynko/snake-loader": patch
3+
---
4+
5+
Internal hardening: stable React keys on snake cells (no per-tick remount), pure tick computation (safe under React Strict Mode), live response to `prefers-reduced-motion` changes, and defensive clamping of `cellSize` and `speed` props.

src/SnakeLoader.tsx

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, type CSSProperties } from "react";
1+
import type { CSSProperties } from "react";
22
import { GRID_SIZE, useSnakeGame } from "./useSnakeGame";
33
import {
44
resolveTheme,
@@ -19,6 +19,9 @@ export interface SnakeLoaderProps {
1919
"aria-label"?: string;
2020
}
2121

22+
const MIN_CELL_SIZE = 1;
23+
const MIN_SPEED = 1;
24+
2225
export function SnakeLoader(props: SnakeLoaderProps) {
2326
const {
2427
theme = "nokia",
@@ -32,15 +35,14 @@ export function SnakeLoader(props: SnakeLoaderProps) {
3235
"aria-label": ariaLabel = "Loading",
3336
} = props;
3437

35-
const resolved = useMemo(
36-
() => resolveTheme(theme, colors, effects),
37-
[theme, colors, effects],
38-
);
38+
const safeCellSize = Math.max(MIN_CELL_SIZE, cellSize);
39+
const safeSpeed = Math.max(MIN_SPEED, speed);
3940

40-
const game = useSnakeGame({ speed, paused });
41+
const resolved = resolveTheme(theme, colors, effects);
42+
const game = useSnakeGame({ speed: safeSpeed, paused });
4143

42-
const cssVars = {
43-
"--snake-loader-cell-size": `${cellSize}px`,
44+
const cssVars: CSSProperties = {
45+
"--snake-loader-cell-size": `${safeCellSize}px`,
4446
"--snake-loader-snake": resolved.colors.snake,
4547
"--snake-loader-food": resolved.colors.food,
4648
"--snake-loader-grid": resolved.colors.grid,
@@ -51,14 +53,16 @@ export function SnakeLoader(props: SnakeLoaderProps) {
5153
const classes = [
5254
"snake-loader",
5355
`snake-loader--${theme}`,
54-
resolved.effects.pulse ? "snake-loader--pulse" : "",
55-
resolved.effects.glow ? "snake-loader--glow" : "",
56-
game.status === "dying" ? "snake-loader--dying" : "",
57-
className ?? "",
56+
resolved.effects.pulse && "snake-loader--pulse",
57+
resolved.effects.glow && "snake-loader--glow",
58+
game.status === "dying" && "snake-loader--dying",
59+
className,
5860
]
5961
.filter(Boolean)
6062
.join(" ");
6163

64+
const gridPx = GRID_SIZE * safeCellSize;
65+
6266
return (
6367
<div
6468
className={classes}
@@ -70,10 +74,10 @@ export function SnakeLoader(props: SnakeLoaderProps) {
7074
<div
7175
className="snake-loader__grid"
7276
style={{
73-
width: GRID_SIZE * cellSize,
74-
height: GRID_SIZE * cellSize,
75-
gridTemplateColumns: `repeat(${GRID_SIZE}, ${cellSize}px)`,
76-
gridTemplateRows: `repeat(${GRID_SIZE}, ${cellSize}px)`,
77+
width: gridPx,
78+
height: gridPx,
79+
gridTemplateColumns: `repeat(${GRID_SIZE}, ${safeCellSize}px)`,
80+
gridTemplateRows: `repeat(${GRID_SIZE}, ${safeCellSize}px)`,
7781
}}
7882
>
7983
<div
@@ -82,7 +86,7 @@ export function SnakeLoader(props: SnakeLoaderProps) {
8286
/>
8387
{game.snake.map((cell, i) => (
8488
<div
85-
key={`${cell.x},${cell.y},${i}`}
89+
key={i}
8690
className="snake-loader__cell"
8791
style={{ gridColumn: cell.x + 1, gridRow: cell.y + 1 }}
8892
/>

src/useSnakeGame.ts

Lines changed: 65 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { useEffect, useReducer, useRef } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import { nextDirection, step, type Cell, type Direction } from "./pathing";
33

44
export const GRID_SIZE = 8;
55
const INITIAL_LENGTH = 2;
66
const DYING_TICKS = 12;
7+
const MIN_INTERVAL_MS = 40;
8+
const REDUCED_MOTION_FACTOR = 0.5;
79

810
export type Status = "alive" | "dying";
911

@@ -20,8 +22,6 @@ export interface GameOptions {
2022
paused: boolean;
2123
}
2224

23-
type Action = { type: "tick" };
24-
2525
function randomEmptyCell(snake: Cell[]): Cell {
2626
const occupied = new Set(snake.map((c) => `${c.x},${c.y}`));
2727
const empty: Cell[] = [];
@@ -41,76 +41,86 @@ function initialState(): GameState {
4141
for (let i = 0; i < INITIAL_LENGTH; i++) {
4242
snake.unshift({ x: startX + i, y: startY });
4343
}
44-
const food = randomEmptyCell(snake);
45-
return { snake, food, direction: "right", status: "alive", dyingTicks: 0 };
44+
return {
45+
snake,
46+
food: randomEmptyCell(snake),
47+
direction: "right",
48+
status: "alive",
49+
dyingTicks: 0,
50+
};
4651
}
4752

48-
function reducer(state: GameState, action: Action): GameState {
49-
switch (action.type) {
50-
case "tick": {
51-
if (state.status === "dying") {
52-
const next = state.dyingTicks + 1;
53-
return next >= DYING_TICKS
54-
? initialState()
55-
: { ...state, dyingTicks: next };
56-
}
53+
function tick(state: GameState): GameState {
54+
if (state.status === "dying") {
55+
const next = state.dyingTicks + 1;
56+
return next >= DYING_TICKS
57+
? initialState()
58+
: { ...state, dyingTicks: next };
59+
}
5760

58-
const { snake, food, direction } = state;
59-
const nextDir = nextDirection(snake, food, direction, GRID_SIZE, GRID_SIZE);
60-
const newHead = step(snake[0], nextDir);
61-
62-
const outOfBounds =
63-
newHead.x < 0 ||
64-
newHead.x >= GRID_SIZE ||
65-
newHead.y < 0 ||
66-
newHead.y >= GRID_SIZE;
67-
const hitSelf = snake
68-
.slice(0, -1)
69-
.some((c) => c.x === newHead.x && c.y === newHead.y);
70-
71-
if (outOfBounds || hitSelf) {
72-
return { ...state, status: "dying", dyingTicks: 0 };
73-
}
61+
const { snake, food, direction } = state;
62+
const nextDir = nextDirection(snake, food, direction, GRID_SIZE, GRID_SIZE);
63+
const head = step(snake[0], nextDir);
7464

75-
const ate = newHead.x === food.x && newHead.y === food.y;
76-
const newSnake = ate
77-
? [newHead, ...snake]
78-
: [newHead, ...snake.slice(0, -1)];
79-
const newFood = ate ? randomEmptyCell(newSnake) : food;
65+
const outOfBounds =
66+
head.x < 0 || head.x >= GRID_SIZE || head.y < 0 || head.y >= GRID_SIZE;
67+
const hitSelf = snake
68+
.slice(0, -1)
69+
.some((c) => c.x === head.x && c.y === head.y);
8070

81-
return { ...state, snake: newSnake, food: newFood, direction: nextDir };
82-
}
83-
default:
84-
return state;
71+
if (outOfBounds || hitSelf) {
72+
return { ...state, status: "dying", dyingTicks: 0 };
8573
}
74+
75+
const ate = head.x === food.x && head.y === food.y;
76+
const newSnake = ate ? [head, ...snake] : [head, ...snake.slice(0, -1)];
77+
const newFood = ate ? randomEmptyCell(newSnake) : food;
78+
79+
return { ...state, snake: newSnake, food: newFood, direction: nextDir };
80+
}
81+
82+
function computeInterval(speed: number, reducedMotion: boolean): number {
83+
const effective = reducedMotion ? speed * REDUCED_MOTION_FACTOR : speed;
84+
return Math.max(MIN_INTERVAL_MS, 1000 / Math.max(1, effective));
8685
}
8786

88-
export function useSnakeGame(opts: GameOptions): GameState {
89-
const { speed, paused } = opts;
90-
const [state, dispatch] = useReducer(reducer, null, initialState);
91-
const rafRef = useRef<number | null>(null);
92-
const lastTickRef = useRef<number>(0);
87+
export function useSnakeGame({ speed, paused }: GameOptions): GameState {
88+
const [state, setState] = useState<GameState>(initialState);
89+
const stateRef = useRef(state);
90+
stateRef.current = state;
9391

9492
useEffect(() => {
9593
if (paused) return;
9694

97-
const reduced =
98-
typeof window !== "undefined" &&
99-
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
100-
const effectiveSpeed = reduced ? speed * 0.5 : speed;
101-
const interval = Math.max(40, 1000 / Math.max(1, effectiveSpeed));
95+
const mql =
96+
typeof window !== "undefined" && typeof window.matchMedia === "function"
97+
? window.matchMedia("(prefers-reduced-motion: reduce)")
98+
: null;
99+
100+
let interval = computeInterval(speed, mql?.matches ?? false);
101+
const onMotionChange = () => {
102+
interval = computeInterval(speed, mql?.matches ?? false);
103+
};
104+
mql?.addEventListener("change", onMotionChange);
105+
106+
let rafId = 0;
107+
let lastTick = 0;
102108

103109
const loop = (t: number) => {
104-
if (t - lastTickRef.current >= interval) {
105-
lastTickRef.current = t;
106-
dispatch({ type: "tick" });
110+
if (lastTick === 0) lastTick = t;
111+
if (t - lastTick >= interval) {
112+
lastTick = t;
113+
const next = tick(stateRef.current);
114+
stateRef.current = next;
115+
setState(next);
107116
}
108-
rafRef.current = requestAnimationFrame(loop);
117+
rafId = requestAnimationFrame(loop);
109118
};
110-
rafRef.current = requestAnimationFrame(loop);
119+
rafId = requestAnimationFrame(loop);
111120

112121
return () => {
113-
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
122+
cancelAnimationFrame(rafId);
123+
mql?.removeEventListener("change", onMotionChange);
114124
};
115125
}, [speed, paused]);
116126

0 commit comments

Comments
 (0)