Skip to content

feat(blog): phase-2 core-primitives adoption sweep#142

Open
olliethedev wants to merge 10 commits into
mainfrom
feat/blog-phase2-sweep
Open

feat(blog): phase-2 core-primitives adoption sweep#142
olliethedev wants to merge 10 commits into
mainfrom
feat/blog-phase2-sweep

Conversation

@olliethedev

@olliethedev olliethedev commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Part of #136 — completes the blog reference sweep (follows #141's createResource/useForm migration). Blog is the reference implementation the seven remaining plugin sweeps should copy.

#136 checklist mapping

#136 item Commit Notes
Replace direct sonner imports with useNotify() feat(blog): replace direct sonner toasts with useNotify in image field image-field.tsx was the only remaining importer; also drops an accidental duplicate toast.error in the upload catch
Convert UI strings to t("blog.<area>.<name>", "Default") feat(blog): convert UI strings to useTranslate with override-wins localization fallback ~76 keys across 16 components, incl. previously hardcoded strings (post navigation Previous/Next, search modal defaults, tags placeholder, editor placeholder) and client-side validation messages
Wrap permission-sensitive controls in <CanAccess> feat(blog): gate drafts/new/edit routes with permissions and wrap delete control in CanAccess Blog has no drafts-nav/new-post/edit buttons in its UI — the delete button in the edit form is the only in-page control; drafts/new/edit pages are gated via the ComposedRoute permission prop
Move list/filter state to useListState feat(blog): sync post search query to the URL via useListState The search query was blog's only list/filter useState; namespace "blog-posts", ?q= with history: replace, clean URL when empty
Remove now-dead blog-local code refactor(blog): reuse core useDebounce and regenerate blog registry Local useDebounce re-exports the core hook; BLOG_LOCALIZATION default-merge plumbing removed from all components (the constant stays as the BlogLocalization type source and default catalog)
Add unit tests test(blog): cover notify, CanAccess delete gating, useListState round-trip, and i18n precedence 10 new jsdom tests in src/plugins/blog/__tests__/client-sweep.test.tsx

Conventions established (for the remaining sweeps)

  • i18n: localization?.CONSTANT ?? t("plugin.area.name", "English default") — an app-provided overrides.localization wins byte-for-byte (public API unchanged), otherwise t() with a string-literal default so scripts/extract-i18n-keys.ts keeps working. Key format mirrors the constant: BLOG_FORMS_TITLE_LABELblog.forms.titleLabel. Params use core {{param}} interpolation. New docs page: docs/content/docs/i18n.mdx (key catalog, regenerable via the extract script).
  • Access control: resources are <plugin>:<entity> with CRUD verbs — blog:draft/read (drafts route), blog:post/create (new), blog:post/update (edit), blog:post/delete (delete control, with params={{ id }}). Route gating via ComposedRoute's permission prop; in-page controls via <CanAccess>.
  • List state: useListState("<plugin>-<list>", ...) (here "blog-posts"), defaults omitted from the URL, history: "replace" for type-ahead state.

Non-negotiables

  • Public component/hook APIs unchanged (no prop/export changes; overrides.localization behaves as before).
  • No providers configured ⇒ identical behavior: t() returns English defaults, <CanAccess>/permission are no-ops without an auth provider, useNotify() falls back to sonner, useListState is inert without router search-param bindings.

Proof

$ rg 'from "sonner"' packages/stack/src/plugins/blog
(no matches)

Out of scope (noted for phase 3): meta/SEO generator strings in plugin.tsx (not hook contexts), AI-context registration strings, and the localization override field type itself.

Core fixes surfaced by the sweep (E2E)

The URL-synced search exposed two pre-existing framework-glue bugs, fixed in fix(core): stabilize route identity and search-modal effect for URL-synced search:

  • createTanStackPageOptions / createReactRouterPage resolved the route on every render, so a search-param navigation produced a new PageComponent identity and remounted the whole page subtree (closing the search modal on TanStack). getRoute() is now memoized, matching what RouteRenderer already did.
  • SearchModal's effect was keyed on the externalResults array identity, so every async result set re-invoked searchFnsetListStatesetSearchParams, restarting the router navigation and looping forever on React Router/Next.js (E2E timeout). Result syncing is now a separate effect.

Verified locally: smoke.blog.spec.ts (18 tests) passes on all three codegen frameworks (Next.js, TanStack, React Router).

Test plan

  • pnpm build, pnpm typecheck, pnpm lint, pnpm test — all green (362 tests, incl. 10 new)
  • knip --strict clean; registry JSON regenerated via build-registry
  • CI: registry E2E + Next.js codegen E2E

Made with Cursor

olliethedev and others added 6 commits July 6, 2026 14:21
Co-authored-by: Cursor <cursoragent@cursor.com>
…alization fallback

Every user-visible string resolves as localization?.KEY ?? t("blog.<area>.<name>", "English default"):
an app-provided overrides.localization still wins byte-for-byte, otherwise the string goes through
the i18n provider (or its literal English default without one). Client resolver schemas now carry
translatable required-field messages; server-side schemas.ts is unchanged. Adds the i18n key
reference docs page with the blog key catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ete control in CanAccess

Routes declare ComposedRoute permission (blog:draft/read, blog:post/create, blog:post/update)
and the edit form's delete button/dialog is wrapped in <CanAccess resource="blog:post"
action="delete">. Without an auth provider everything renders exactly as before.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces the search modal's useState query with useListState("blog-posts", { q }) —
?q=... while searching (history: replace), clean URL when empty.

Co-authored-by: Cursor <cursoragent@cursor.com>
Blog's local useDebounce copy now re-exports the shared hook from
@btst/stack/plugins/client/hooks (useThrottle stays plugin-local).
Registry JSON regenerated for the updated component sources.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-trip, and i18n precedence

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

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
better-stack-docs Ready Ready Preview, Comment Jul 6, 2026 6:19pm
better-stack-playground Ready Ready Preview, Comment Jul 6, 2026 6:19pm

Request Review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Shadcn registry validated — no registry changes detected.

…ynced search

Typing in the blog search modal syncs ?q= to the URL via useListState. Two
framework-level issues surfaced in codegen E2E:

- TanStack/React Router page factories resolved the route on every render,
  producing a new PageComponent identity per search-param navigation and
  remounting the page subtree (closing the modal). Memoize getRoute() like
  RouteRenderer already does.
- SearchModal's effect was keyed on the externalResults identity, so each
  async result set re-invoked searchFn -> setListState -> setSearchParams,
  restarting the navigation and looping forever on React Router/Next.js.
  Split result syncing into its own effect.

Co-authored-by: Cursor <cursoragent@cursor.com>
… query length bound

- useListState degrades to plain local state when the router has no
  getSearchParams/setSearchParams bindings, instead of silently dropping
  updates (search kept working via URL only with a full binding before).
- SearchModal seeds its input from the persisted query in the open event
  and only notifies searchFn once the debounce settles on a value the
  parent has not seen, so opening the dialog no longer clears ?q=.
- PostListQuerySchema bounds query to 200 chars.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 85e79ae. Configure here.

Comment thread packages/stack/src/client/hooks/use-list-state.ts Outdated
… clear

The UI Builder page embeds its own sonner Toaster next to the app-root
one, so a toast can resolve to two elements. Now that page subtrees are
no longer remounted on every render, both copies stay visible until
dismissal, tripping strict mode. All copies share one store and dismiss
together, so waiting on the first is equivalent.

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

Local fallback state is now a patch overlaid on the parsed URL state
instead of an all-or-nothing snapshot, so routers exposing only
getSearchParams keep re-reading the URL (back/forward, external
changes) while writes land in the overlay instead of being dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

1 participant