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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ permissions:
contents: read

jobs:
# Split from Build so a lint or test failure reports in ~1min instead of
# waiting on the full Next production bundle. The npm cache is shared, so the
# duplicated install costs little.
check:
name: Lint & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
# Matches the Dockerfile's node:22-alpine, so CI checks what ships.
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

# `eslint` with no args lints the whole project under the flat config.
# `--max-warnings=0` keeps the tree at zero warnings now that it is
# clean — a new warning is a failure rather than scrollback noise.
- name: Lint
run: npm run lint -- --max-warnings=0

# Vitest needs no database: the suite runs against PGlite/in-memory
# fixtures, same as the build's fallback driver.
- name: Test
run: npm test

build:
name: Build
runs-on: ubuntu-latest
Expand Down
78 changes: 53 additions & 25 deletions components/column/column-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,28 +103,6 @@ export function ColumnCard({ column }: { column: Column }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: column.id });

if (!type) {
return (
<div className="flex w-[min(360px,calc(100vw-1rem))] shrink-0 snap-start flex-col rounded-lg border border-destructive/50 bg-card p-4 text-sm sm:w-[360px] sm:snap-none">
<p className="font-medium">Unknown column type</p>
<p className="mt-1 text-muted-foreground">
Type <code>{column.typeId}</code> is not registered.
</p>
<Button
variant="destructive"
size="sm"
className="mt-3"
onClick={() => removeColumn(column.id)}
>
Remove
</Button>
</div>
);
}

const Icon = type.icon;
const ItemRenderer = type.ItemRenderer;

const paginated = type?.capabilities?.paginated === true;

const alertTerms = useMemo(
Expand Down Expand Up @@ -184,6 +162,12 @@ export function ColumnCard({ column }: { column: Column }) {
// visually but never clears the query, so re-opening shows what they last
// typed (until they reload or manually clear).
useEffect(() => {
// set-state-in-effect is suppressed, not fixed: the open state is sticky
// by design, so it can't be derived as `searchOpen || searchActive` —
// that would auto-close the row the moment the query is cleared, which is
// exactly the behaviour the note below rules out. The cascade is one
// bounded extra render on the mount-with-existing-query path.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (searchActive && !searchOpen) setSearchOpen(true);
// intentionally only react to searchActive flipping true — don't auto-
// close when the operator clears the query mid-type; let them keep the
Expand All @@ -197,6 +181,10 @@ export function ColumnCard({ column }: { column: Column }) {
// re-fires cleanly.
useEffect(() => {
if (pendingSearchOpen !== column.id) return;
// Syncing local state from an external store signal is effect-shaped by
// nature — the keypress happens outside this component and is routed here
// through the store, so there is no render-time value to derive from.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearchOpen(true);
requestAnimationFrame(() => searchInputRef.current?.focus());
clearPendingSearchOpen(column.id);
Expand All @@ -206,8 +194,15 @@ export function ColumnCard({ column }: { column: Column }) {
// the latest typeId/config without forcing a tear-down on every config edit.
const typeIdRef = useRef(column.typeId);
const configRef = useRef(column.config);
typeIdRef.current = column.typeId;
configRef.current = column.config;
// Synced in an effect rather than during render: mutating a ref mid-render
// is unsafe under concurrent rendering (a render that never commits would
// still have written). The useRef seeds hold the correct first-render values
// and this effect runs before the interval below can ever fire, so the
// closure still reads current values on every tick.
useEffect(() => {
typeIdRef.current = column.typeId;
configRef.current = column.config;
});

useEffect(() => {
const intervalSeconds = column.refreshIntervalSeconds;
Expand Down Expand Up @@ -297,7 +292,12 @@ export function ColumnCard({ column }: { column: Column }) {
// on a fresh function reference and tear down its own subscription
// mid-tick).
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh;
// Synced post-commit for the same reason as the config refs above; the
// refresh effect below only dereferences `.current` asynchronously, inside
// the pending-refresh branch, so it never reads a stale frame.
useEffect(() => {
onRefreshRef.current = onRefresh;
});
const refreshInFlightRef = useRef(false);
useEffect(() => {
if (!isPendingRefresh) return;
Expand All @@ -310,6 +310,34 @@ export function ColumnCard({ column }: { column: Column }) {
});
}, [isPendingRefresh, isFetching, column.id, clearPendingRefresh]);

// Unknown-type bail-out sits below every hook on purpose. A column's type
// resolves through the plugin registry, so `type` can flip from undefined to
// defined (or back) across renders as plugins register — returning early
// above the hooks would change hook order between renders and throw. The
// hooks above all tolerate a missing `type`: `paginated` optional-chains and
// the refresh effect guards with its own `if (!type) return`.
if (!type) {
return (
<div className="flex w-[min(360px,calc(100vw-1rem))] shrink-0 snap-start flex-col rounded-lg border border-destructive/50 bg-card p-4 text-sm sm:w-[360px] sm:snap-none">
<p className="font-medium">Unknown column type</p>
<p className="mt-1 text-muted-foreground">
Type <code>{column.typeId}</code> is not registered.
</p>
<Button
variant="destructive"
size="sm"
className="mt-3"
onClick={() => removeColumn(column.id)}
>
Remove
</Button>
</div>
);
}

const Icon = type.icon;
const ItemRenderer = type.ItemRenderer;

async function onLoadMore() {
if (!paginated) return;
setIsLoadingMore(true);
Expand Down
5 changes: 5 additions & 0 deletions components/dialogs/version-history-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export function VersionHistoryDialog({ deckId, open, onOpenChange }: Props) {
useEffect(() => {
if (!open || !deckId) return;
let cancelled = false;
// Entering the loading state as the fetch is kicked off is the point of
// this effect — the data lives outside React and only the dialog opening
// can trigger the read. Removing the cascade would mean moving snapshot
// loading behind Suspense, which is out of scope here.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
loadDeckSnapshots(deckId)
.then((rows) => {
Expand Down
17 changes: 17 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
]),
{
rules: {
// Treat a leading underscore as "deliberately unused". Plugin ConfigForm
// components that take no configuration still declare their props
// parameter as `_` to document the contract they satisfy, and destructured
// rest-siblings are the idiomatic way to drop a key from an object.
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
},
]);

export default eslintConfig;
2 changes: 1 addition & 1 deletion lib/columns/plugins/crates/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function ConfigForm({ value, onChange }: ConfigFormProps<CratesConfig>) {
</Select>
<p className="text-xs text-muted-foreground">
<strong>Trending</strong> tracks the last 90 days of downloads — the
best signal for "what's hot right now."{" "}
best signal for &ldquo;what&rsquo;s hot right now.&rdquo;{" "}
<strong>Recently updated</strong> surfaces crates with active
maintenance.
</p>
Expand Down
4 changes: 2 additions & 2 deletions lib/columns/plugins/npm/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function ConfigForm({ value, onChange }: ConfigFormProps<NpmConfig>) {
onChange={(e) => onChange({ ...value, query: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
npm's search endpoint requires a query; leave as <code>javascript</code>
npm&rsquo;s search endpoint requires a query; leave as <code>javascript</code>
{" "}for a broad popularity stream, or scope to a keyword (single word
for substring match across name/description/keywords).
</p>
Expand All @@ -56,7 +56,7 @@ function ConfigForm({ value, onChange }: ConfigFormProps<NpmConfig>) {
</Select>
<p className="text-xs text-muted-foreground">
Heavy-weights the chosen axis to 0.8; <strong>Combined</strong>{" "}
mirrors npm's default ranking blend.
mirrors npm&rsquo;s default ranking blend.
</p>
</div>
</div>
Expand Down
5 changes: 0 additions & 5 deletions lib/columns/plugins/producthunt/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ import {
type ProductHuntMeta,
} from "./plugin";

const MODE_LABELS: Record<ProductHuntConfig["mode"], string> = {
today: "Today's launches",
topic: "Filter by topic",
};

function ConfigForm({ value, onChange }: ConfigFormProps<ProductHuntConfig>) {
return (
<div className="grid gap-3">
Expand Down
2 changes: 1 addition & 1 deletion lib/columns/plugins/pypi/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function ConfigForm({ value, onChange }: ConfigFormProps<PypiConfig>) {
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
<strong>Updates</strong> and <strong>New packages</strong> read PyPI's
<strong>Updates</strong> and <strong>New packages</strong> read PyPI&rsquo;s
public RSS feeds (last ~40 entries, time-ordered).{" "}
<strong>Top · 30d</strong> ranks the top 8000 packages by 30-day
downloads via the community-maintained mirror — no API key required.
Expand Down