Skip to content

Adopt Cache Components, retire dead surface area, and add CI#410

Merged
leerob merged 13 commits into
mainfrom
chore/cache-components-cleanup-ci
Jun 9, 2026
Merged

Adopt Cache Components, retire dead surface area, and add CI#410
leerob merged 13 commits into
mainfrom
chore/cache-components-cleanup-ci

Conversation

@leerob

@leerob leerob commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR lands the combined output of several parallel agent sessions on the directory app: a Vercel observability audit that turned into cost/bleeding fixes, a Cache Components (PPR) migration, a staff-level maintainability pass, and a CI baseline. It merges cleanly with main, reconciling the recently-merged owner menu (#405), slug preservation (#408), and scan sandbox fix (#409).

Why

Digging through Vercel observability, firewall, and logs surfaced concrete bleeding:

  • The sync-ambassadors cron 500'd on all ~172 runs/week (stale Airtable credentials) — pure noise and wasted invocations.
  • The legacy public rules API (/api, /api/[slug], /api/popular, /api/plugins/[slug]) and the npx install-plugin CLI have been hard-down since mid-February: bot protection serves challenge HTML to non-browser clients, and logs show zero successful consumers. Deleting beats carving firewall exceptions for unused endpoints.
  • The homepage burned 3.57M ISR writes and 45.8 GB-hrs/week (7.6s p50 / 183s p99 renders): every plugin install called revalidatePath("/"), re-rendering a page that fetches all plugins.
  • getSession() in shared components forced ~230K dynamic renders/week on /members, profiles, and /plugins/new — pages that are 95% static shell.
  • /mcp/[slug] rendered ~57K times/week as a serverless function just to issue a redirect().

Separately, a maintainability review found the safe-action middleware logging every action's inputs/results (user PII) to stdout, an admin flag on the cookie-scoped Supabase client that made privilege escalation a one-liner, ~2,000 lines of orphaned dead code, two 800–1,000-line components, and 56 type errors on main — unsurprising, since nothing ran tsc or Biome.

What changed

Roughly one commit per workstream, in order:

  1. Remove dead code — orphaned rules-era modules (subscribe form/action, rules search, save-rule button, videos dataset, ui/cursor.tsx, pricing/format utils, etc.); formatNumber callers consolidate on formatCount.
  2. Remove the failing ambassadors cron + its route and AIRTABLE_* env docs.
  3. Retire the legacy public API + install-plugin CLI (/api/members stays — the members page uses it).
  4. Serve /mcp/[slug] as a permanent edge redirect via next.config.mjs (excludes /mcp/new; can't match /mcp/x/edit).
  5. Stop logging action payloads; split the privileged Supabase client — failures-only logging (action name + error), and utils/supabase/server can no longer escalate: service-role access lives solely in admin-client with documented responsibilities.
  6. Extract shared plugin types and split oversized componentslib/plugins/types.ts is the single source for plugin domain types/schemas (queries, scanner, actions, and admin UI used to re-declare drifting copies); plugin-detail.tsx (1,020 lines) splits into focused plugins/detail/* modules; the duplicated draft-component editor moves to forms/component-draft-editor.tsx; hand-rolled pagination loops collapse into fetchAllPages.
  7. Adopt Cache Components (cacheComponents: true)
    • Public reads in data/queries.ts get "use cache" + cacheLife + entity tags (plugins, plugin-{slug}, users, followers-{id}, stars-{userId}, …). Viewer-scoped reads (own profile, settings, admin) stay uncached.
    • Server actions invalidate tags instead of paths: updateTag for read-your-own-writes mutations, revalidateTag(…, "max") for counters (stars/installs) that can refresh in the background — an install no longer re-renders the homepage.
    • Pages move params/getSession() reads behind Suspense so shells prerender and only the personalized hole streams; Header/JoinCTA/ScrollToTop wrap their usePathname readers in Suspense.
    • OG image routes cache rendered PNG bytes (ImageResponse isn't serializable); scan drain/recover routes invalidate plugin tags when verdicts land.
  8. Add CI — Biome + tsc --noEmit on every push/PR, and fix what it caught (stable React keys, unused state, the clickable-div upload control becomes a real button).

The merge commit reconciles with what landed on main meanwhile: ComponentDraft now carries stored slugs so the extracted editor preserves #408's fix (including parser-derived slugs in the create flow); getUserPlugins keeps #405's includeInactive with the owner variant uncached; the new owner-menu actions move from revalidatePath to updateTag so publish/unpublish/delete stay immediately visible.

Test plan

Verified locally:

  • bunx biome ci . — clean (errors: 0)
  • bun run typecheck — clean (was 56 errors on the pre-PR baseline)
  • bun run build — passes: 3,343 pages, homepage fully static, every dynamic route shows (partial prerender), no route regressed to fully dynamic

Worth checking on the preview deploy:

  • Homepage renders and search/leaderboard work; install a plugin and confirm the count updates without a full page rebuild
  • Plugin detail: rules/MCP/skill tabs, Add to Cursor deeplinks, copy buttons
  • Owner flows: edit a GitHub-imported plugin and save without changes → no slug churn, no needless rescan (Preserve stored component slugs in the plugin edit form #408 regression check); publish/unpublish/delete from the owner menu → change visible immediately
  • Star a plugin → starred list on your profile updates immediately
  • /mcp/<some-slug> 308s to /plugins/mcp-<some-slug>; /mcp/new still serves the submission form
  • OG images render for /, a plugin, a profile, a company
  • /admin/plugins moderation queue loads (admin-gated, uncached)
  • Profile pages stream in behind the static shell (header nav fallback shouldn't flash)

Caveats

  • Local builds need the current Supabase publishable key (the keys in older .env.local files were rotated; grab the sb_publishable_… key from the dashboard).
  • The homepage now has a ~5-minute revalidate window via getTotalUsers; event-driven tags handle everything plugin-related.
  • Non-owners visiting another user's /u/[slug]/settings now see "User not found" instead of a redirect.
  • No alerting was added for cron failures (the ambassadors cron failed silently 172×/week for months) — worth a follow-up.

Made with Cursor


Note

Medium Risk
Broad caching and invalidation changes affect how quickly listings, profiles, and moderation state update site-wide; mis-tagged cache entries could show stale plugin or user data until invalidation runs.

Overview
Adopts Next.js Cache Components (cacheComponents: true) so public reads cache with entity tags (plugins, plugin-{slug}, users, etc.) and server actions invalidate via updateTag / revalidateTag instead of revalidatePath("/")—so installs, stars, and moderation no longer force full homepage rebuilds.

Removes dead or failing surface area: legacy public rules/plugin JSON APIs, the install-plugin CLI consumers, the Airtable ambassadors cron (and env docs), subscribeAction, and assorted unused UI/utils. /mcp/:slug becomes a config redirect to /plugins/mcp-:slug (excluding new / edit routes).

Hardens ops and DX: GitHub CI runs Biome + typecheck; safe-action middleware logs failures only (no client inputs/results). Plugin domain types and componentInputSchema move to lib/plugins/types; plugin detail and forms split into plugins/detail/* and component-draft-editor.

Rendering model: homepage, members, sitemap, and OG images use "use cache"; auth-, admin-, and owner-gated content streams behind Suspense; header/nav/CTA wrap usePathname readers; scan drain invalidates plugin tags when scans finish.

Reviewed by Cursor Bugbot for commit d8d2c03. Bugbot is set up for automated code reviews on this repo. Configure here.

leerob and others added 10 commits June 8, 2026 13:07
None of these modules are imported anywhere: the subscribe form/action,
rules search, save-rule button, how-to, filter input, search title, the
628-line ui/cursor.tsx animation, the videos dataset, and the pricing/
format/auth-client utils. formatNumber callers move to the existing
formatCount in lib/utils so utils/format.ts can go too.

Also drops the unused getSupabaseCookies helper, dead state in the
mobile menu, and renames update-mcp-listing.tsx -> .ts since it
contains no JSX.

Co-authored-by: Cursor <cursoragent@cursor.com>
Vercel observability shows the hourly Airtable sync has 500'd on every
run (~172/week) since the Airtable credentials went stale, so it only
produces noise and wasted invocations. Ambassador flags can be set
directly in Supabase; drop the cron, its route, and the AIRTABLE_* env
docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Bot protection has been serving a browser challenge to non-browser
clients since mid-February, so /api, /api/[slug], /api/popular,
/api/plugins/[slug], and the npx install-plugin CLI that consumed them
have all been hard down for months with zero successful consumers in
the logs. Rather than carve out firewall exceptions for endpoints with
no known users, delete them: rules-era integrations are superseded by
plugin pages and Cursor deeplinks. /api/members stays, as the members
page uses it.

Co-authored-by: Cursor <cursoragent@cursor.com>
The page was nothing but a redirect() to /plugins/mcp-[slug], yet it
rendered ~57K times a week as a serverless function. A permanent
redirect in next.config handles it at the edge for free. The regex
excludes /mcp/new (the submission form) and can't match two-segment
paths like /mcp/x/edit.

Co-authored-by: Cursor <cursoragent@cursor.com>
The safe-action middleware logged every action's input and result to
stdout, which lands user PII (emails, profile fields) in Vercel logs.
Log only failures, and only the action name plus error.

The server client also lost its `admin` flag: privileged access now
lives solely in utils/supabase/admin-client, so a cookie-scoped client
can never silently escalate to the service role, and the two access
paths are documented at their source.

Co-authored-by: Cursor <cursoragent@cursor.com>
plugin-detail.tsx had grown past 1,000 lines and plugin-form.tsx past
800, mixing data shaping, deeplink construction, clipboard handling,
and per-component-type rendering in single files.

- lib/plugins/types.ts is now the single source for plugin domain
  types (PluginRow, PluginComponent, scan verdicts/flags) and the
  component input schema; data/queries, the scanner, actions, and
  admin UI import from it instead of re-declaring drifting copies.
- plugin-detail is split into focused modules under plugins/detail/
  (rules/MCP/generic sections, deeplinks, scan banner, logo, copy
  button, add-to-cursor CTA).
- The duplicated draft-component editor in plugin-form and
  edit-plugin-form moves to forms/component-draft-editor.tsx.
- The hand-rolled 1,000-row pagination loops in data queries collapse
  into utils/supabase/pagination.fetchAllPages.
- github-plugin/parse exports its auth headers + rate-limited fetch
  for the scanner instead of keeping private near-duplicates.

Co-authored-by: Cursor <cursoragent@cursor.com>
Observability showed the homepage alone burning 3.57M ISR writes and
45.8 GB-hrs a week (7.6s p50 / 183s p99 renders): every plugin install
called revalidatePath("/"), forcing a full re-render of a page that
fetches all plugins, and getSession() in shared layout pieces forced
~230K dynamic renders a week across /members, profiles, and
/plugins/new.

With cacheComponents enabled:

- Public reads in data/queries are cached with "use cache" +
  cacheLife, tagged per entity (plugins, plugin-{slug}, users,
  user-{slug}, followers-{id}, stars-{userId}, companies, mcps).
  Viewer-scoped reads (own profile, settings, admin) stay uncached.
- Server actions invalidate tags instead of paths: updateTag for
  content edits that must be read-your-own-writes, revalidateTag
  (max profile) for counters like stars/installs that can refresh in
  the background, so an install no longer re-renders the homepage.
- Pages move session/params reads behind Suspense boundaries with
  static fallbacks (loader/gate pattern), so shells prerender and only
  the personalized hole streams. Header/JoinCTA/ScrollToTop wrap their
  usePathname readers in Suspense for the same reason.
- OG image routes cache rendered PNG bytes (ImageResponse itself is
  not serializable); the scan drain/recover routes invalidate plugin
  tags when verdicts land.

Co-authored-by: Cursor <cursoragent@cursor.com>
The repo had no CI: nothing ran Biome or tsc on push, which is how 56
type errors and a pile of lint violations accumulated on main. Adds a
GitHub Actions workflow that runs biome ci and bun tsc --noEmit on
pushes and PRs.

Biome now respects .gitignore, skips dist/, formats Tailwind CSS
directives, and the remaining violations are fixed: array-index React
keys replaced with stable keys, unused params/state dropped, and the
clickable upload-logo div becomes a real button (keyboard accessible
for free). .gitignore also excludes local research scratch data so
PII-bearing CSVs can never be committed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reconciliations beyond textual conflicts:

- ComponentDraft carries the stored component slug (and the GitHub
  parser slug in the create form) so the extracted draft editor
  preserves PR #408's slug-preservation fix instead of regenerating
  slugs from names on every save.
- getUserPlugins keeps PR #405's includeInactive option; the owner
  variant stays uncached so publish/unpublish/delete are immediately
  visible, while the public variant is cached under the plugins tag.
- The owner-menu actions (delete-plugin, toggle-plugin-listing) move
  from revalidatePath to updateTag, matching the Cache Components
  invalidation model.
- plugin-owner-menu imports PluginRow from lib/plugins/types.
- plugin-detail keeps the split layout with PR #405's owner menu and
  unpublished banner; scan.ts keeps PR #409's tmpfs fix plus the shared
  type constants.

Co-authored-by: Cursor <cursoragent@cursor.com>
The owner menu, alert dialog, and its actions merged from main predate
the stricter Biome setup on this branch; biome check --write only.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cursor-directory Ready Ready Preview, Comment Jun 9, 2026 1:29pm

Request Review

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Rescan leaves plugin cache stale
    • rescanPluginAction now selects the slug and calls updateTag("plugins") and updateTag(plugin-${slug}) (previously it only revalidated the admin path, leaving the public detail page stale), and updateMCPListingAction now also invalidates mcp-${slug} to match toggleMCPListingAction.
  • ✅ Fixed: Follow skips members list tag
    • toggleFollowAction now also calls updateTag("users") so the members directory cached under the users tag refreshes its follower_count after a follow or unfollow.

Create PR

Or push these changes by commenting:

@cursor push 5f7ed45f42
Preview (5f7ed45f42)
diff --git a/apps/cursor/src/actions/review-flagged-plugin.ts b/apps/cursor/src/actions/review-flagged-plugin.ts
--- a/apps/cursor/src/actions/review-flagged-plugin.ts
+++ b/apps/cursor/src/actions/review-flagged-plugin.ts
@@ -76,14 +76,16 @@
   .action(async ({ parsedInput: { pluginId } }) => {
     const supabase = await createClient();
 
-    const { error: resetError } = await supabase
+    const { data: plugin, error: resetError } = await supabase
       .from("plugins")
       .update({ scan_status: "pending" })
-      .eq("id", pluginId);
+      .eq("id", pluginId)
+      .select("slug")
+      .single();
 
-    if (resetError) {
+    if (resetError || !plugin) {
       throw new ActionError(
-        `Failed to reset scan state: ${resetError.message}`,
+        `Failed to reset scan state: ${resetError?.message ?? "not found"}`,
       );
     }
 
@@ -97,5 +99,7 @@
     }
 
     revalidatePath("/admin/plugins");
+    updateTag("plugins");
+    updateTag(`plugin-${plugin.slug}`);
     return { success: true };
   });

diff --git a/apps/cursor/src/actions/toggle-follow-action.ts b/apps/cursor/src/actions/toggle-follow-action.ts
--- a/apps/cursor/src/actions/toggle-follow-action.ts
+++ b/apps/cursor/src/actions/toggle-follow-action.ts
@@ -24,8 +24,10 @@
       const supabase = await createClient();
 
       const invalidate = () => {
-        // Profile follower counts, the followed user's followers list, and
-        // the current user's following list must all reflect the change.
+        // Profile follower counts, the members directory's cached follower
+        // counts, the followed user's followers list, and the current user's
+        // following list must all reflect the change.
+        updateTag("users");
         updateTag(`user-${slug}`);
         updateTag(`followers-${userId}`);
         updateTag(`following-${currentUserId}`);

diff --git a/apps/cursor/src/actions/update-mcp-listing.ts b/apps/cursor/src/actions/update-mcp-listing.ts
--- a/apps/cursor/src/actions/update-mcp-listing.ts
+++ b/apps/cursor/src/actions/update-mcp-listing.ts
@@ -39,7 +39,7 @@
         })
         .eq("id", id)
         .eq("owner_id", userId)
-        .select("id")
+        .select("id, slug")
         .single();
 
       if (error) {
@@ -47,6 +47,7 @@
       }
 
       updateTag("mcps");
+      updateTag(`mcp-${data.slug}`);
 
       return data;
     },

You can send follow-ups to the cloud agent here.

Comment thread apps/cursor/src/actions/review-flagged-plugin.ts
Comment thread apps/cursor/src/actions/toggle-follow-action.ts
Comment thread .gitignore Outdated
The nuqs next/app adapter reads useSearchParams(), which is runtime data
under Cache Components, so every useQueryState consumer suspended during
prerendering and collapsed into empty Suspense fallbacks — the homepage
shell shipped without the leaderboard. Replace it with a static adapter
that renders defaults at prerender time and syncs from location.search
after hydration, thread a cache-scoped timestamp through the leaderboard
to replace its non-deterministic Date.now() calls, cache both pages
wholesale with use cache + cacheLife (SWR), move the members join CTA to
a client-side cookie check, and tighten action tag invalidation.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Rescan pending not cache-flushed
    • Moved the revalidatePath/updateTag cache invalidation to run immediately after the scan_status='pending' DB update commits and before the enqueue try-catch, so a queue failure can no longer leave cached views showing the stale status.

Create PR

Or push these changes by commenting:

@cursor push 4e48bb73d7
Preview (4e48bb73d7)
diff --git a/apps/cursor/src/actions/review-flagged-plugin.ts b/apps/cursor/src/actions/review-flagged-plugin.ts
--- a/apps/cursor/src/actions/review-flagged-plugin.ts
+++ b/apps/cursor/src/actions/review-flagged-plugin.ts
@@ -89,6 +89,14 @@
       );
     }
 
+    // The status reset must reach cached readers (detail banner, leaderboard)
+    // right away, so invalidate before enqueueing — a queue failure must not
+    // leave cached views showing the stale status when the row is already
+    // pending. The drain route only invalidates again once the scan ends.
+    revalidatePath("/admin/plugins");
+    updateTag("plugins");
+    updateTag(`plugin-${plugin.slug}`);
+
     try {
       await enqueuePluginScan(pluginId);
       kickDrainAfterResponse();
@@ -98,10 +106,5 @@
       );
     }
 
-    revalidatePath("/admin/plugins");
-    // The status reset must reach cached readers (detail banner, leaderboard)
-    // right away; the drain route only invalidates again once the scan ends.
-    updateTag("plugins");
-    updateTag(`plugin-${plugin.slug}`);
     return { success: true };
   });

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 9834409. Configure here.

Comment thread apps/cursor/src/actions/review-flagged-plugin.ts Outdated
@leerob

leerob commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@cursor push 4e48bb7

In rescanPluginAction the plugin's scan_status is committed to 'pending'
before enqueuePluginScan runs. Cache invalidation lived after the enqueue
try/catch, so an enqueue failure threw before invalidation and left
cached readers (detail banner, leaderboard) showing the stale status
until tag expiry. Move revalidatePath/updateTag to run immediately after
the DB update commits so the reset always reaches cached readers.

Applied via @cursor push command
@leerob leerob merged commit 5aca0ff into main Jun 9, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants