From afec8cd100fe0f8f57ea366c724009bc16ecb008 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:01:22 +0000 Subject: [PATCH 1/7] feat(core): add notify, i18n, and useListState primitives Introduce StackProvider notify/i18n props with useNotify() and useTranslate() hooks, plus URL-synced useListState for list pages. Router search params were already on StackRouter from #127. Part of #131, #132, #133. Plugin adoption deferred to #136. Co-authored-by: Cursor --- docs/content/docs/api-reference.mdx | 20 +++ packages/stack/scripts/extract-i18n-keys.ts | 47 ++++++ .../src/__tests__/i18n-provider.test.tsx | 99 ++++++++++++ .../stack/src/__tests__/list-state.test.ts | 89 +++++++++++ .../src/__tests__/notify-provider.test.tsx | 100 ++++++++++++ .../src/__tests__/use-list-state.test.tsx | 117 ++++++++++++++ .../stack/src/client/hooks/use-list-state.ts | 109 +++++++++++++ packages/stack/src/client/index.ts | 12 ++ packages/stack/src/context/i18n.tsx | 57 +++++++ packages/stack/src/context/index.ts | 7 + packages/stack/src/context/notify.tsx | 66 ++++++++ packages/stack/src/context/provider.tsx | 44 ++++-- packages/stack/src/shared/i18n-types.ts | 31 ++++ packages/stack/src/shared/interpolate.ts | 14 ++ packages/stack/src/shared/list-state.ts | 147 ++++++++++++++++++ packages/stack/src/shared/notify-types.ts | 29 ++++ 16 files changed, 975 insertions(+), 13 deletions(-) create mode 100644 packages/stack/scripts/extract-i18n-keys.ts create mode 100644 packages/stack/src/__tests__/i18n-provider.test.tsx create mode 100644 packages/stack/src/__tests__/list-state.test.ts create mode 100644 packages/stack/src/__tests__/notify-provider.test.tsx create mode 100644 packages/stack/src/__tests__/use-list-state.test.tsx create mode 100644 packages/stack/src/client/hooks/use-list-state.ts create mode 100644 packages/stack/src/context/i18n.tsx create mode 100644 packages/stack/src/context/notify.tsx create mode 100644 packages/stack/src/shared/i18n-types.ts create mode 100644 packages/stack/src/shared/interpolate.ts create mode 100644 packages/stack/src/shared/list-state.ts create mode 100644 packages/stack/src/shared/notify-types.ts diff --git a/docs/content/docs/api-reference.mdx b/docs/content/docs/api-reference.mdx index db6096c7..0f761e9e 100644 --- a/docs/content/docs/api-reference.mdx +++ b/docs/content/docs/api-reference.mdx @@ -106,3 +106,23 @@ type Sitemap = Array; ### useBasePath + +### useNotify + + + +Notifications are configured via the `notify` prop on `StackProvider`. Without an override, `useNotify()` routes to sonner toasts. + +### useTranslate + + + +Without an `i18n` provider, `useTranslate()` returns the English default with `{{param}}` interpolation. + +### useListState + +URL-synced list state (filters, tabs, pagination) via the router's `getSearchParams` / `setSearchParams` contract. Export path: `@btst/stack/client`. + +For SSR loaders, read initial state with `parseListStateFromSearchParams(namespace, schema, requestSearchParams)`. + + diff --git a/packages/stack/scripts/extract-i18n-keys.ts b/packages/stack/scripts/extract-i18n-keys.ts new file mode 100644 index 00000000..d074391a --- /dev/null +++ b/packages/stack/scripts/extract-i18n-keys.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env tsx +/** + * Scan plugin sources for `t("key", "Default")` / `useTranslate()` call sites. + * + * Usage: + * pnpm exec tsx packages/stack/scripts/extract-i18n-keys.ts + * + * Output: JSON map of file → [{ key, defaultValue }] for #136 migration reference. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = join(import.meta.dirname, "../src/plugins"); + +const CALL_PATTERN = + /\bt\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]((?:\\.|[^"'`\\])*)["'`]/g; + +function walk(dir: string, files: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full, files); + } else if (/\.(tsx?|jsx?)$/.test(entry)) { + files.push(full); + } + } + return files; +} + +type KeyEntry = { key: string; defaultValue: string }; + +const results: Record = {}; + +for (const file of walk(ROOT)) { + const content = readFileSync(file, "utf8"); + const entries: KeyEntry[] = []; + + for (const match of content.matchAll(CALL_PATTERN)) { + entries.push({ key: match[1], defaultValue: match[2] }); + } + + if (entries.length > 0) { + results[relative(join(import.meta.dirname, ".."), file)] = entries; + } +} + +console.log(JSON.stringify(results, null, 2)); diff --git a/packages/stack/src/__tests__/i18n-provider.test.tsx b/packages/stack/src/__tests__/i18n-provider.test.tsx new file mode 100644 index 00000000..fb8e8433 --- /dev/null +++ b/packages/stack/src/__tests__/i18n-provider.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + StackProvider, + useTranslate, + type StackI18nProvider, +} from "../context"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +describe("useTranslate", () => { + it("returns the default string when no i18n provider is configured", async () => { + let t: ReturnType | undefined; + function Probe() { + t = useTranslate(); + return null; + } + await render( + + + , + ); + + expect(t!("blog.posts.create", "Create Post")).toBe("Create Post"); + }); + + it("interpolates {{param}} placeholders in the default string", async () => { + let t: ReturnType | undefined; + function Probe() { + t = useTranslate(); + return null; + } + await render( + + + , + ); + + expect( + t!("blog.posts.deleted", "Deleted {{title}}", { title: "Hello" }), + ).toBe("Deleted Hello"); + }); + + it("delegates to the consumer translate function", async () => { + const translate = vi.fn( + (key: string, defaultValue: string, params?: Record) => + `[${key}] ${defaultValue} ${params?.title ?? ""}`.trim(), + ); + const i18n: StackI18nProvider = { translate }; + + let t: ReturnType | undefined; + function Probe() { + t = useTranslate(); + return null; + } + + await render( + + + , + ); + + const result = t!("blog.posts.deleted", "Deleted {{title}}", { + title: "Draft", + }); + + expect(translate).toHaveBeenCalledWith( + "blog.posts.deleted", + "Deleted {{title}}", + { title: "Draft" }, + ); + expect(result).toBe("[blog.posts.deleted] Deleted {{title}} Draft"); + }); +}); diff --git a/packages/stack/src/__tests__/list-state.test.ts b/packages/stack/src/__tests__/list-state.test.ts new file mode 100644 index 00000000..701cac9b --- /dev/null +++ b/packages/stack/src/__tests__/list-state.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + parseListStateFromSearchParams, + resolveListStateHistoryMode, + serializeListStateToSearchParams, +} from "../shared/list-state"; + +const schema = { + tab: { type: "string" as const, default: "pending" }, + page: { type: "number" as const, default: 1 }, + filter: { type: "string" as const, default: "", history: "replace" as const }, +}; + +describe("parseListStateFromSearchParams", () => { + it("returns defaults for an empty query string", () => { + expect( + parseListStateFromSearchParams("comments-moderation", schema, ""), + ).toEqual({ tab: "pending", page: 1, filter: "" }); + }); + + it("parses non-default values from URL params", () => { + const params = new URLSearchParams("tab=spam&page=3&filter=hello"); + expect( + parseListStateFromSearchParams("comments-moderation", schema, params), + ).toEqual({ tab: "spam", page: 3, filter: "hello" }); + }); + + it("falls back to defaults for invalid numbers", () => { + const params = new URLSearchParams("page=abc"); + expect( + parseListStateFromSearchParams("comments-moderation", schema, params) + .page, + ).toBe(1); + }); +}); + +describe("serializeListStateToSearchParams", () => { + it("omits fields equal to their defaults", () => { + const params = serializeListStateToSearchParams( + "comments-moderation", + schema, + { tab: "pending", page: 1, filter: "" }, + ); + expect(params.toString()).toBe(""); + }); + + it("serializes only deviating fields", () => { + const params = serializeListStateToSearchParams( + "comments-moderation", + schema, + { tab: "spam", page: 3, filter: "" }, + ); + expect(params.toString()).toBe("tab=spam&page=3"); + }); + + it("preserves unrelated params in the base query string", () => { + const base = new URLSearchParams("foo=bar"); + const params = serializeListStateToSearchParams( + "comments-moderation", + schema, + { tab: "spam", page: 1, filter: "" }, + base, + ); + expect(params.get("foo")).toBe("bar"); + expect(params.get("tab")).toBe("spam"); + expect(params.has("page")).toBe(false); + }); +}); + +describe("resolveListStateHistoryMode", () => { + it("defaults to push when no replace fields are updated", () => { + expect(resolveListStateHistoryMode(schema, { tab: "spam", page: 2 })).toBe( + false, + ); + }); + + it("uses replace when a replace-history field is updated", () => { + expect(resolveListStateHistoryMode(schema, { filter: "x" })).toBe(true); + }); + + it("honors an explicit replace option", () => { + expect(resolveListStateHistoryMode(schema, { tab: "spam" }, true)).toBe( + true, + ); + expect(resolveListStateHistoryMode(schema, { filter: "x" }, false)).toBe( + false, + ); + }); +}); diff --git a/packages/stack/src/__tests__/notify-provider.test.tsx b/packages/stack/src/__tests__/notify-provider.test.tsx new file mode 100644 index 00000000..1265ae9b --- /dev/null +++ b/packages/stack/src/__tests__/notify-provider.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StackProvider, useNotify, type StackNotifyProvider } from "../context"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +const toastInfo = vi.fn(); +const toastWarning = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccess(...args), + error: (...args: unknown[]) => toastError(...args), + info: (...args: unknown[]) => toastInfo(...args), + warning: (...args: unknown[]) => toastWarning(...args), + }, +})); + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + toastSuccess.mockClear(); + toastError.mockClear(); + toastInfo.mockClear(); + toastWarning.mockClear(); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); +}); + +async function render(ui: React.ReactElement) { + await act(async () => { + root.render(ui); + }); +} + +describe("useNotify", () => { + it("uses sonner by default when no notify prop is configured", async () => { + let notify: StackNotifyProvider | undefined; + function Probe() { + notify = useNotify(); + return null; + } + await render( + + + , + ); + + notify!.success!("Saved"); + notify!.error!("Failed", { description: "Try again" }); + + expect(toastSuccess).toHaveBeenCalledWith("Saved", undefined); + expect(toastError).toHaveBeenCalledWith("Failed", { + description: "Try again", + }); + }); + + it("routes notifications through a custom provider", async () => { + const customSuccess = vi.fn(); + const customError = vi.fn(); + let notify: StackNotifyProvider | undefined; + + function Probe() { + notify = useNotify(); + return null; + } + + await render( + + + , + ); + + notify!.success!("Custom saved"); + notify!.error!("Custom failed"); + notify!.info!("Still sonner"); + + expect(customSuccess).toHaveBeenCalledWith("Custom saved"); + expect(customError).toHaveBeenCalledWith("Custom failed"); + expect(toastInfo).toHaveBeenCalledWith("Still sonner", undefined); + expect(toastSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx new file mode 100644 index 00000000..ecc42357 --- /dev/null +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useListState } from "../client/hooks/use-list-state"; +import { StackProvider } from "../context"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const schema = { + tab: { type: "string" as const, default: "pending" }, + page: { type: "number" as const, default: 1 }, + filter: { type: "string" as const, default: "", history: "replace" as const }, +}; + +function createMockRouter() { + let params = new URLSearchParams(); + const setSearchParams = vi.fn( + (next: URLSearchParams, opts?: { replace?: boolean }) => { + params = new URLSearchParams(next.toString()); + void opts; + }, + ); + return { + getSearchParams: () => new URLSearchParams(params.toString()), + setSearchParams, + }; +} + +describe("useListState", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.restoreAllMocks(); + }); + + async function renderHook(onCapture: (value: unknown) => void) { + const router = createMockRouter(); + function Probe() { + const [state, setState] = useListState("comments-moderation", schema); + onCapture({ state, setState }); + return null; + } + await act(async () => { + root.render( + + + , + ); + }); + return router; + } + + it("round-trips state through the router search params", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + expect(captured.state).toEqual({ tab: "pending", page: 1, filter: "" }); + expect(router.setSearchParams).not.toHaveBeenCalled(); + + await act(async () => { + captured.setState({ tab: "spam", page: 3 }); + }); + + expect(captured.state).toEqual({ tab: "spam", page: 3, filter: "" }); + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const call = router.setSearchParams.mock.calls[0]; + expect(call).toBeDefined(); + const [nextParams] = call!; + expect(nextParams.toString()).toBe("tab=spam&page=3"); + }); + + it("uses replace history for rapid filter changes", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ filter: "abc" }); + }); + + expect(router.setSearchParams).toHaveBeenCalledWith( + expect.any(URLSearchParams), + { replace: true }, + ); + }); + + it("uses push history for discrete tab changes", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ tab: "spam" }); + }); + + expect(router.setSearchParams).toHaveBeenCalledWith( + expect.any(URLSearchParams), + { replace: false }, + ); + }); +}); diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts new file mode 100644 index 00000000..1aa5c159 --- /dev/null +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -0,0 +1,109 @@ +"use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useStackOrNull } from "../../context/provider"; +import type { InferListState, ListStateSchema } from "../../shared/list-state"; +import { + parseListStateFromSearchParams, + resolveListStateHistoryMode, + serializeListStateToSearchParams, +} from "../../shared/list-state"; + +export type { + InferListState, + ListStateField, + ListStateSchema, +} from "../../shared/list-state"; +export { + listStateParamKey, + parseListStateFromSearchParams, + serializeListStateToSearchParams, +} from "../../shared/list-state"; + +export type SetListStateOptions = { + /** Force `replace` vs `push` history semantics for this update */ + replace?: boolean; +}; + +export type SetListState = ( + updates: + | Partial> + | ((prev: InferListState) => Partial>), + options?: SetListStateOptions, +) => void; + +/** + * Sync list UI state (filters, tabs, pagination) with URL search params. + * + * Defaults are omitted from the URL. Field-level `history: "replace"` is used + * for rapid changes; discrete changes (tab/page) default to `push` so the back + * button undoes them. + * + * The `namespace` identifies this list state for SSR helpers; URL keys use + * the schema field names directly. + * + * @example + * ```tsx + * const [state, setState] = useListState("comments-moderation", { + * tab: { type: "string", default: "pending" }, + * page: { type: "number", default: 1 }, + * }); + * // URL: ?tab=spam&page=3 + * ``` + */ +export function useListState( + namespace: string, + schema: S, +): [InferListState, SetListState] { + const stack = useStackOrNull(); + const router = stack?.router; + const getSearchParams = router?.getSearchParams; + const setSearchParams = router?.setSearchParams; + + const [urlVersion, bumpUrlVersion] = useState(0); + + useEffect(() => { + if (typeof window === "undefined") return; + const onPopState = () => bumpUrlVersion((v) => v + 1); + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + const searchParams = useMemo(() => { + void urlVersion; + return getSearchParams?.() ?? new URLSearchParams(); + }, [getSearchParams, urlVersion]); + + const state = useMemo( + () => parseListStateFromSearchParams(namespace, schema, searchParams), + [namespace, schema, searchParams], + ); + + const setState = useCallback>( + (updates, options) => { + const patch = typeof updates === "function" ? updates(state) : updates; + if (!patch || Object.keys(patch).length === 0) return; + + const nextState = { ...state, ...patch }; + const replace = resolveListStateHistoryMode( + schema, + patch, + options?.replace, + ); + + if (setSearchParams && getSearchParams) { + const current = getSearchParams(); + const nextParams = serializeListStateToSearchParams( + namespace, + schema, + nextState, + current, + ); + setSearchParams(nextParams, { replace }); + bumpUrlVersion((v) => v + 1); + } + }, + [namespace, schema, state, setSearchParams, getSearchParams], + ); + + return [state, setState]; +} diff --git a/packages/stack/src/client/index.ts b/packages/stack/src/client/index.ts index bec01889..ab078a0d 100644 --- a/packages/stack/src/client/index.ts +++ b/packages/stack/src/client/index.ts @@ -114,3 +114,15 @@ export { sitemapEntryToXmlString } from "./sitemap-utils"; export { metaElementsToObject } from "./meta-utils"; export { normalizePath } from "./path-utils"; + +export { + useListState, + parseListStateFromSearchParams, + serializeListStateToSearchParams, + listStateParamKey, + type InferListState, + type ListStateField, + type ListStateSchema, + type SetListState, + type SetListStateOptions, +} from "./hooks/use-list-state"; diff --git a/packages/stack/src/context/i18n.tsx b/packages/stack/src/context/i18n.tsx new file mode 100644 index 00000000..cfddb5a8 --- /dev/null +++ b/packages/stack/src/context/i18n.tsx @@ -0,0 +1,57 @@ +"use client"; +import { createContext, useCallback, useContext, useMemo } from "react"; +import { interpolate } from "../shared/interpolate"; +import type { StackI18nProvider } from "../shared/i18n-types"; + +const I18nContext = createContext(null); + +export function StackI18nBoundary({ + i18n, + children, +}: { + i18n?: StackI18nProvider; + children?: React.ReactNode; +}) { + return ( + {children} + ); +} + +export type TranslateFn = ( + key: string, + defaultValue: string, + params?: Record, +) => string; + +/** + * Returns a `t(key, defaultValue, params?)` function for translatable UI strings. + * + * Without an `i18n` provider on `StackProvider`, returns the default English + * string with `{{param}}` interpolation — identical to hardcoded strings today. + * + * @example + * ```tsx + * const t = useTranslate(); + * return ; + * ``` + */ +export function useTranslate(): TranslateFn { + const i18n = useContext(I18nContext); + + const fallback = useCallback( + (key, defaultValue, params) => interpolate(defaultValue, params), + [], + ); + + return useMemo(() => { + if (!i18n) return fallback; + + return (key, defaultValue, params) => + i18n.translate(key, defaultValue, params); + }, [i18n, fallback]); +} + +/** @internal Access the raw i18n provider (or `null` when none is set). */ +export function useI18nContext(): StackI18nProvider | null { + return useContext(I18nContext); +} diff --git a/packages/stack/src/context/index.ts b/packages/stack/src/context/index.ts index 6687d730..f67ddb70 100644 --- a/packages/stack/src/context/index.ts +++ b/packages/stack/src/context/index.ts @@ -1,9 +1,16 @@ export * from "./provider"; export * from "./router"; export { CanAccess, useCan, useIdentity } from "./auth"; +export { useNotify, defaultNotifyProvider } from "./notify"; +export { useTranslate, type TranslateFn } from "./i18n"; export type { CanParams, StackAuthProvider, StackIdentity, StackServerAuthProvider, } from "../shared/auth-types"; +export type { + StackNotifyProvider, + NotifyOptions, +} from "../shared/notify-types"; +export type { StackI18nProvider } from "../shared/i18n-types"; diff --git a/packages/stack/src/context/notify.tsx b/packages/stack/src/context/notify.tsx new file mode 100644 index 00000000..280fbf59 --- /dev/null +++ b/packages/stack/src/context/notify.tsx @@ -0,0 +1,66 @@ +"use client"; +import { createContext, useContext, useMemo } from "react"; +import { toast } from "sonner"; +import type { + NotifyOptions, + StackNotifyProvider, +} from "../shared/notify-types"; + +const NotifyContext = createContext | null>(null); + +/** Sonner-backed defaults used when no custom `notify` prop is configured. */ +export const defaultNotifyProvider: Required = { + success: (message: string, options?: NotifyOptions) => { + toast.success(message, options); + }, + error: (message: string, options?: NotifyOptions) => { + toast.error(message, options); + }, + info: (message: string, options?: NotifyOptions) => { + toast.info(message, options); + }, + warning: (message: string, options?: NotifyOptions) => { + toast.warning(message, options); + }, +}; + +function mergeNotifyProvider( + custom: StackNotifyProvider | undefined, +): Required { + return { + success: custom?.success ?? defaultNotifyProvider.success, + error: custom?.error ?? defaultNotifyProvider.error, + info: custom?.info ?? defaultNotifyProvider.info, + warning: custom?.warning ?? defaultNotifyProvider.warning, + }; +} + +export function StackNotifyBoundary({ + notify, + children, +}: { + notify?: StackNotifyProvider; + children?: React.ReactNode; +}) { + const value = useMemo(() => mergeNotifyProvider(notify), [notify]); + + return ( + {children} + ); +} + +/** + * Returns notification methods routed through the `notify` provider on + * `StackProvider`, falling back to sonner toasts when no override is set. + * + * @example + * ```tsx + * const notify = useNotify(); + * notify.success("Post saved"); + * notify.error("Failed to delete", { description: "Try again later" }); + * ``` + */ +export function useNotify(): Required { + const notify = useContext(NotifyContext); + return notify ?? defaultNotifyProvider; +} diff --git a/packages/stack/src/context/provider.tsx b/packages/stack/src/context/provider.tsx index f88574a8..a48e81ba 100644 --- a/packages/stack/src/context/provider.tsx +++ b/packages/stack/src/context/provider.tsx @@ -1,7 +1,11 @@ "use client"; import { createContext, useContext, type ReactNode } from "react"; import type { StackAuthProvider } from "../shared/auth-types"; +import type { StackI18nProvider } from "../shared/i18n-types"; +import type { StackNotifyProvider } from "../shared/notify-types"; import { StackAuthBoundary } from "./auth"; +import { StackI18nBoundary } from "./i18n"; +import { StackNotifyBoundary } from "./notify"; import type { StackApiConfig, StackRouter, @@ -155,6 +159,8 @@ export function StackProvider< router, api, auth, + notify, + i18n, }: { children?: ReactNode; overrides?: StackProviderOverrides; @@ -168,6 +174,16 @@ export function StackProvider< * checks pass. */ auth?: StackAuthProvider; + /** + * Optional notification provider. When omitted, sonner toasts are used via + * `useNotify()`. + */ + notify?: StackNotifyProvider; + /** + * Optional i18n provider. When omitted, `useTranslate()` returns English + * defaults with `{{param}}` interpolation. + */ + i18n?: StackI18nProvider; }) { const staticRouter = resolveStaticRouter(router); const value: Omit, "router"> = { @@ -182,23 +198,25 @@ export function StackProvider< children ); - if (router?.useRouter) { - return ( - - {content} - - ); - } - - return ( + const stackTree = router?.useRouter ? ( + + {content} + + ) : ( {content} ); + + return ( + + {stackTree} + + ); } /** diff --git a/packages/stack/src/shared/i18n-types.ts b/packages/stack/src/shared/i18n-types.ts new file mode 100644 index 00000000..a99a774d --- /dev/null +++ b/packages/stack/src/shared/i18n-types.ts @@ -0,0 +1,31 @@ +/** + * Optional i18n provider, passed to `StackProvider` via the `i18n` prop. + * + * Without a provider, `useTranslate()` returns the default English string with + * `{{param}}` interpolation — zero setup cost for English-only apps. + * + * @example + * ```tsx + * const i18n: StackI18nProvider = { + * translate: (key, defaultValue, params) => + * t(key, { defaultValue, ...params }), + * }; + * + * + * ``` + */ +export interface StackI18nProvider { + /** + * Translate a namespaced key. Receives the English default and optional + * interpolation params when no translation exists for the key. + */ + translate: ( + key: string, + defaultValue: string, + params?: Record, + ) => string; + /** Return the active locale (optional) */ + getLocale?: () => string; + /** Switch locale (optional) */ + changeLocale?: (locale: string) => void; +} diff --git a/packages/stack/src/shared/interpolate.ts b/packages/stack/src/shared/interpolate.ts new file mode 100644 index 00000000..7506bfff --- /dev/null +++ b/packages/stack/src/shared/interpolate.ts @@ -0,0 +1,14 @@ +/** + * Replace `{{key}}` placeholders in a template with stringified param values. + */ +export function interpolate( + template: string, + params?: Record, +): string { + if (!params) return template; + + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + const value = params[key]; + return value == null ? "" : String(value); + }); +} diff --git a/packages/stack/src/shared/list-state.ts b/packages/stack/src/shared/list-state.ts new file mode 100644 index 00000000..e1db003a --- /dev/null +++ b/packages/stack/src/shared/list-state.ts @@ -0,0 +1,147 @@ +import { interpolate } from "./interpolate"; + +export type ListStateFieldType = "string" | "number" | "boolean"; + +export interface ListStateField< + T extends ListStateFieldType = ListStateFieldType, +> { + type: T; + default: T extends "string" ? string : T extends "number" ? number : boolean; + /** + * How URL history is updated when this field changes. + * - `"push"` (default): discrete changes (tab switch, page change) — back button undoes. + * - `"replace"`: rapid changes (typing in a filter) — no extra history entries. + */ + history?: "push" | "replace"; +} + +export type ListStateSchema = Record; + +export type InferListStateValue = + F["type"] extends "string" + ? string + : F["type"] extends "number" + ? number + : boolean; + +export type InferListState = { + [K in keyof S]: InferListStateValue; +}; + +/** + * URL query param key for a list-state field. + * The `namespace` argument identifies the hook instance for SSR helpers but + * does not prefix URL keys — field names are used directly (e.g. `?tab=spam`). + */ +export function listStateParamKey( + _namespace: string, + fieldKey: string, +): string { + return fieldKey; +} + +function parseFieldValue( + field: F, + raw: string | null, +): InferListStateValue { + if (raw == null || raw === "") { + return field.default as InferListStateValue; + } + + switch (field.type) { + case "number": { + const parsed = Number(raw); + return ( + Number.isFinite(parsed) ? parsed : field.default + ) as InferListStateValue; + } + case "boolean": + return (raw === "true") as InferListStateValue; + default: + return raw as InferListStateValue; + } +} + +function serializeFieldValue(value: unknown): string { + return String(value); +} + +function valuesEqual(a: unknown, b: unknown): boolean { + return a === b; +} + +/** + * Parse list state from URL search params (SSR-safe — pass the request URL's + * search string or a `URLSearchParams` instance). + */ +export function parseListStateFromSearchParams( + namespace: string, + schema: S, + searchParams: URLSearchParams | string, +): InferListState { + const params = + typeof searchParams === "string" + ? new URLSearchParams(searchParams) + : searchParams; + + const state = {} as InferListState; + + for (const fieldKey of Object.keys(schema) as Array) { + const field = schema[fieldKey]!; + const paramKey = listStateParamKey(namespace, fieldKey); + state[fieldKey as keyof S] = parseFieldValue( + field, + params.get(paramKey), + ) as InferListState[keyof S]; + } + + return state; +} + +/** + * Serialize list state into URL search params. Default values are omitted for + * clean URLs. + */ +export function serializeListStateToSearchParams( + namespace: string, + schema: S, + state: InferListState, + baseParams?: URLSearchParams, +): URLSearchParams { + const params = new URLSearchParams(baseParams?.toString()); + + for (const fieldKey of Object.keys(schema) as Array) { + const field = schema[fieldKey]!; + const paramKey = listStateParamKey(namespace, fieldKey); + const value = state[fieldKey as keyof S]; + + if (valuesEqual(value, field.default)) { + params.delete(paramKey); + } else { + params.set(paramKey, serializeFieldValue(value)); + } + } + + return params; +} + +/** + * Decide whether a list-state update should use `replace` or `push` history. + */ +export function resolveListStateHistoryMode( + schema: S, + updates: Partial>, + explicit?: boolean, +): boolean { + if (explicit !== undefined) return explicit; + + for (const fieldKey of Object.keys(updates) as Array) { + if (schema[fieldKey]?.history === "replace") { + return true; + } + } + + return false; +} + +export { interpolate }; diff --git a/packages/stack/src/shared/notify-types.ts b/packages/stack/src/shared/notify-types.ts new file mode 100644 index 00000000..dea8ccc0 --- /dev/null +++ b/packages/stack/src/shared/notify-types.ts @@ -0,0 +1,29 @@ +/** + * Options passed to notification methods. + */ +export interface NotifyOptions { + /** Optional longer description shown below the message */ + description?: string; +} + +/** + * Pluggable notification provider, passed to `StackProvider` via the `notify` prop. + * + * When omitted, sonner toasts are used as the default implementation. + * + * @example + * ```tsx + * const notify: StackNotifyProvider = { + * success: (msg) => myToast.success(msg), + * error: (msg) => myToast.error(msg), + * }; + * + * + * ``` + */ +export interface StackNotifyProvider { + success?: (message: string, options?: NotifyOptions) => void; + error?: (message: string, options?: NotifyOptions) => void; + info?: (message: string, options?: NotifyOptions) => void; + warning?: (message: string, options?: NotifyOptions) => void; +} From fc13ffbc901e84a99879903bb6955247cbdb25f2 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:31:54 +0000 Subject: [PATCH 2/7] ci: retrigger codegen E2E Co-authored-by: Cursor From 320e7644a80c4956127e5d078c5f9dd6e4f6835a Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:52:39 +0000 Subject: [PATCH 3/7] fix: read fresh URL state in useListState setState Address Bugbot feedback: derive next state from getSearchParams() instead of a stale render closure, and re-read params each render so router-driven URL changes are not masked by useMemo caching. Co-authored-by: Cursor --- .../src/__tests__/use-list-state.test.tsx | 20 +++++++ .../stack/src/client/hooks/use-list-state.ts | 52 +++++++++++-------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx index ecc42357..49af1271 100644 --- a/packages/stack/src/__tests__/use-list-state.test.tsx +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -114,4 +114,24 @@ describe("useListState", () => { { replace: false }, ); }); + + it("preserves prior URL fields when batched in one event", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ tab: "spam" }); + captured.setState({ page: 3 }); + }); + + const lastCall = + router.setSearchParams.mock.calls[ + router.setSearchParams.mock.calls.length - 1 + ]; + expect(lastCall).toBeDefined(); + const [nextParams] = lastCall!; + expect(nextParams.toString()).toBe("tab=spam&page=3"); + }); }); diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts index 1aa5c159..dec73f8a 100644 --- a/packages/stack/src/client/hooks/use-list-state.ts +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useStackOrNull } from "../../context/provider"; import type { InferListState, ListStateSchema } from "../../shared/list-state"; import { @@ -68,41 +68,47 @@ export function useListState( return () => window.removeEventListener("popstate", onPopState); }, []); - const searchParams = useMemo(() => { - void urlVersion; - return getSearchParams?.() ?? new URLSearchParams(); - }, [getSearchParams, urlVersion]); - - const state = useMemo( - () => parseListStateFromSearchParams(namespace, schema, searchParams), - [namespace, schema, searchParams], + // Read search params on every render so router-driven URL changes are picked + // up even when `getSearchParams` is referentially stable. `urlVersion` covers + // back/forward when the parent does not re-render. + void urlVersion; + const state = parseListStateFromSearchParams( + namespace, + schema, + getSearchParams?.() ?? new URLSearchParams(), ); const setState = useCallback>( (updates, options) => { - const patch = typeof updates === "function" ? updates(state) : updates; + if (!setSearchParams || !getSearchParams) return; + + const currentParams = getSearchParams(); + const currentState = parseListStateFromSearchParams( + namespace, + schema, + currentParams, + ); + const patch = + typeof updates === "function" ? updates(currentState) : updates; if (!patch || Object.keys(patch).length === 0) return; - const nextState = { ...state, ...patch }; + const nextState = { ...currentState, ...patch }; const replace = resolveListStateHistoryMode( schema, patch, options?.replace, ); - if (setSearchParams && getSearchParams) { - const current = getSearchParams(); - const nextParams = serializeListStateToSearchParams( - namespace, - schema, - nextState, - current, - ); - setSearchParams(nextParams, { replace }); - bumpUrlVersion((v) => v + 1); - } + const nextParams = serializeListStateToSearchParams( + namespace, + schema, + nextState, + currentParams, + ); + setSearchParams(nextParams, { replace }); + bumpUrlVersion((v) => v + 1); }, - [namespace, schema, state, setSearchParams, getSearchParams], + [namespace, schema, setSearchParams, getSearchParams], ); return [state, setState]; From 48602787b652c07a7e35c7b527ff9686f161b531 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:16:03 +0000 Subject: [PATCH 4/7] fix: coalesce same-event useListState updates into one history entry Multiple setState calls in one event now merge into a single setSearchParams call via microtask batching, and no-op updates skip history entirely, so one back press undoes one user action. Co-authored-by: Cursor --- .../src/__tests__/use-list-state.test.tsx | 39 ++++++++-- .../stack/src/client/hooks/use-list-state.ts | 75 ++++++++++++++----- 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx index 49af1271..89066cbc 100644 --- a/packages/stack/src/__tests__/use-list-state.test.tsx +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -115,7 +115,7 @@ describe("useListState", () => { ); }); - it("preserves prior URL fields when batched in one event", async () => { + it("coalesces same-event updates into a single history entry", async () => { let captured: any; const router = await renderHook((value) => { captured = value; @@ -126,12 +126,37 @@ describe("useListState", () => { captured.setState({ page: 3 }); }); - const lastCall = - router.setSearchParams.mock.calls[ - router.setSearchParams.mock.calls.length - 1 - ]; - expect(lastCall).toBeDefined(); - const [nextParams] = lastCall!; + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const [nextParams] = router.setSearchParams.mock.calls[0]!; expect(nextParams.toString()).toBe("tab=spam&page=3"); }); + + it("merges pending patches into functional updaters within one event", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ page: 2 }); + captured.setState((prev: any) => ({ page: prev.page + 1 })); + }); + + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const [nextParams] = router.setSearchParams.mock.calls[0]!; + expect(nextParams.toString()).toBe("page=3"); + }); + + it("does not push history when the update is a no-op", async () => { + let captured: any; + const router = await renderHook((value) => { + captured = value; + }); + + await act(async () => { + captured.setState({ tab: "pending", page: 1 }); + }); + + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); }); diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts index dec73f8a..90c428e4 100644 --- a/packages/stack/src/client/hooks/use-list-state.ts +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useStackOrNull } from "../../context/provider"; import type { InferListState, ListStateSchema } from "../../shared/list-state"; import { @@ -78,35 +78,74 @@ export function useListState( getSearchParams?.() ?? new URLSearchParams(), ); + // Updates issued in the same tick are coalesced into a single + // `setSearchParams` call. Router bindings commit URL changes + // asynchronously (Next.js `router.push`, React Router `navigate`), so + // consecutive `setState` calls in one event would otherwise read a stale + // URL and drop earlier patches — and each call would add its own history + // entry, breaking one-back-press-undoes-one-action semantics. + const pendingRef = useRef<{ + patch: Partial>; + explicitReplace: boolean | undefined; + } | null>(null); + const setState = useCallback>( (updates, options) => { if (!setSearchParams || !getSearchParams) return; - const currentParams = getSearchParams(); const currentState = parseListStateFromSearchParams( namespace, schema, - currentParams, + getSearchParams(), ); + const pending = pendingRef.current; + const baseState = pending + ? { ...currentState, ...pending.patch } + : currentState; const patch = - typeof updates === "function" ? updates(currentState) : updates; + typeof updates === "function" ? updates(baseState) : updates; if (!patch || Object.keys(patch).length === 0) return; - const nextState = { ...currentState, ...patch }; - const replace = resolveListStateHistoryMode( - schema, - patch, - options?.replace, - ); + if (pending) { + pending.patch = { ...pending.patch, ...patch }; + if (options?.replace !== undefined) { + pending.explicitReplace = options.replace; + } + return; + } - const nextParams = serializeListStateToSearchParams( - namespace, - schema, - nextState, - currentParams, - ); - setSearchParams(nextParams, { replace }); - bumpUrlVersion((v) => v + 1); + pendingRef.current = { + patch: { ...patch }, + explicitReplace: options?.replace, + }; + + queueMicrotask(() => { + const flushed = pendingRef.current; + pendingRef.current = null; + if (!flushed) return; + + const currentParams = getSearchParams(); + const nextState = { + ...parseListStateFromSearchParams(namespace, schema, currentParams), + ...flushed.patch, + }; + const replace = resolveListStateHistoryMode( + schema, + flushed.patch, + flushed.explicitReplace, + ); + const nextParams = serializeListStateToSearchParams( + namespace, + schema, + nextState, + currentParams, + ); + // No-op updates (merged state already matches the URL) must not + // create history entries. + if (nextParams.toString() === currentParams.toString()) return; + setSearchParams(nextParams, { replace }); + bumpUrlVersion((v) => v + 1); + }); }, [namespace, schema, setSearchParams, getSearchParams], ); From ffe99409146111a9b695a5b1e29bf7e3ee1f03ec Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:30:38 +0000 Subject: [PATCH 5/7] fix: batch same-tick useListState updates across hook instances Move the coalescing queue to module scope so concurrent updates from different useListState instances merge into one setSearchParams call instead of the later flush clobbering the earlier one's params. Co-authored-by: Cursor --- .../src/__tests__/use-list-state.test.tsx | 39 +++++ .../stack/src/client/hooks/use-list-state.ts | 147 +++++++++++------- 2 files changed, 132 insertions(+), 54 deletions(-) diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx index 89066cbc..0519685c 100644 --- a/packages/stack/src/__tests__/use-list-state.test.tsx +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -147,6 +147,45 @@ describe("useListState", () => { expect(nextParams.toString()).toBe("page=3"); }); + it("coalesces updates from multiple hook instances in one event", async () => { + const router = createMockRouter(); + let capturedA: any; + let capturedB: any; + + function ProbeA() { + const [state, setState] = useListState("comments-moderation", schema); + capturedA = { state, setState }; + return null; + } + function ProbeB() { + const [state, setState] = useListState("media-library", { + folder: { type: "string" as const, default: "root" }, + }); + capturedB = { state, setState }; + return null; + } + + await act(async () => { + root.render( + + + + , + ); + }); + + await act(async () => { + capturedA.setState({ tab: "spam" }); + capturedB.setState({ folder: "photos" }); + }); + + expect(router.setSearchParams).toHaveBeenCalledTimes(1); + const [nextParams] = router.setSearchParams.mock.calls[0]!; + expect(nextParams.toString()).toBe("tab=spam&folder=photos"); + expect(capturedA.state).toEqual({ tab: "spam", page: 1, filter: "" }); + expect(capturedB.state).toEqual({ folder: "photos" }); + }); + it("does not push history when the update is a no-op", async () => { let captured: any; const router = await renderHook((value) => { diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts index 90c428e4..400689a6 100644 --- a/packages/stack/src/client/hooks/use-list-state.ts +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useStackOrNull } from "../../context/provider"; import type { InferListState, ListStateSchema } from "../../shared/list-state"; import { @@ -31,6 +31,82 @@ export type SetListState = ( options?: SetListStateOptions, ) => void; +interface PendingUpdate { + namespace: string; + schema: ListStateSchema; + patch: Record; + explicitReplace: boolean | undefined; + getSearchParams: () => URLSearchParams; + setSearchParams: ( + next: URLSearchParams, + opts?: { replace?: boolean }, + ) => void; + onFlushed: () => void; +} + +// Same-tick updates from ALL useListState instances are coalesced into one +// `setSearchParams` call. Router bindings commit URL changes asynchronously +// (Next.js `router.push`, React Router `navigate`), so a second update in the +// same event would read a stale URL and drop what the first one wrote — and +// each call would add its own history entry, breaking +// one-back-press-undoes-one-action semantics. The URL is a per-window +// singleton, so a module-level queue is the correct batching scope; `setState` +// only runs in client event handlers, never during SSR. +let pendingQueue: PendingUpdate[] | null = null; + +function enqueueListStateUpdate(update: PendingUpdate): void { + if (pendingQueue) { + pendingQueue.push(update); + return; + } + + pendingQueue = [update]; + queueMicrotask(() => { + const queue = pendingQueue; + pendingQueue = null; + if (!queue || queue.length === 0) return; + + const first = queue[0]!; + const currentParams = first.getSearchParams(); + + let params = currentParams; + let replace = true; + for (const entry of queue) { + const nextState = { + ...parseListStateFromSearchParams( + entry.namespace, + entry.schema, + params, + ), + ...entry.patch, + }; + params = serializeListStateToSearchParams( + entry.namespace, + entry.schema, + nextState as InferListState, + params, + ); + // A single push among the batched updates makes the whole flush a + // push, so the combined action stays one back-press away. + if ( + !resolveListStateHistoryMode( + entry.schema, + entry.patch as Partial>, + entry.explicitReplace, + ) + ) { + replace = false; + } + } + + // No-op updates (merged state already matches the URL) must not create + // history entries. + if (params.toString() === currentParams.toString()) return; + first.setSearchParams(params, { replace }); + for (const entry of queue) entry.onFlushed(); + }); +} + /** * Sync list UI state (filters, tabs, pagination) with URL search params. * @@ -78,73 +154,36 @@ export function useListState( getSearchParams?.() ?? new URLSearchParams(), ); - // Updates issued in the same tick are coalesced into a single - // `setSearchParams` call. Router bindings commit URL changes - // asynchronously (Next.js `router.push`, React Router `navigate`), so - // consecutive `setState` calls in one event would otherwise read a stale - // URL and drop earlier patches — and each call would add its own history - // entry, breaking one-back-press-undoes-one-action semantics. - const pendingRef = useRef<{ - patch: Partial>; - explicitReplace: boolean | undefined; - } | null>(null); - const setState = useCallback>( (updates, options) => { if (!setSearchParams || !getSearchParams) return; - const currentState = parseListStateFromSearchParams( + // Base state = URL state + patches already queued this tick, so + // functional updaters see the values earlier calls just set. + let baseState = parseListStateFromSearchParams( namespace, schema, getSearchParams(), ); - const pending = pendingRef.current; - const baseState = pending - ? { ...currentState, ...pending.patch } - : currentState; + if (pendingQueue) { + for (const entry of pendingQueue) { + if (entry.namespace === namespace) { + baseState = { ...baseState, ...entry.patch }; + } + } + } const patch = typeof updates === "function" ? updates(baseState) : updates; if (!patch || Object.keys(patch).length === 0) return; - if (pending) { - pending.patch = { ...pending.patch, ...patch }; - if (options?.replace !== undefined) { - pending.explicitReplace = options.replace; - } - return; - } - - pendingRef.current = { + enqueueListStateUpdate({ + namespace, + schema, patch: { ...patch }, explicitReplace: options?.replace, - }; - - queueMicrotask(() => { - const flushed = pendingRef.current; - pendingRef.current = null; - if (!flushed) return; - - const currentParams = getSearchParams(); - const nextState = { - ...parseListStateFromSearchParams(namespace, schema, currentParams), - ...flushed.patch, - }; - const replace = resolveListStateHistoryMode( - schema, - flushed.patch, - flushed.explicitReplace, - ); - const nextParams = serializeListStateToSearchParams( - namespace, - schema, - nextState, - currentParams, - ); - // No-op updates (merged state already matches the URL) must not - // create history entries. - if (nextParams.toString() === currentParams.toString()) return; - setSearchParams(nextParams, { replace }); - bumpUrlVersion((v) => v + 1); + getSearchParams, + setSearchParams, + onFlushed: () => bumpUrlVersion((v) => v + 1), }); }, [namespace, schema, setSearchParams, getSearchParams], From 75a1785b40e2d0623d774e676100c76b52b4e117 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:42:20 +0000 Subject: [PATCH 6/7] fix: compare parsed state, not param strings, for useListState no-op detection URLSearchParams serialization is order-sensitive and the URL may hold explicit default values, so string equality could miss state-identical updates and push spurious history entries. Compare parsed field values per entry instead, and use order-insensitive comparison for the final net-no-op check. Part of #133 Co-authored-by: Cursor --- .../src/__tests__/use-list-state.test.tsx | 31 +++++++++++++- .../stack/src/client/hooks/use-list-state.ts | 42 ++++++++++++++----- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx index 0519685c..fdf81859 100644 --- a/packages/stack/src/__tests__/use-list-state.test.tsx +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -13,8 +13,8 @@ const schema = { filter: { type: "string" as const, default: "", history: "replace" as const }, }; -function createMockRouter() { - let params = new URLSearchParams(); +function createMockRouter(initial = "") { + let params = new URLSearchParams(initial); const setSearchParams = vi.fn( (next: URLSearchParams, opts?: { replace?: boolean }) => { params = new URLSearchParams(next.toString()); @@ -186,6 +186,33 @@ describe("useListState", () => { expect(capturedB.state).toEqual({ folder: "photos" }); }); + it("treats state-identical updates as no-ops even when the URL holds explicit defaults", async () => { + // URL contains `tab=pending` (the default) explicitly: serializing the + // same state would drop it and change the query string without changing + // state. That must not create a history entry. + const router = createMockRouter("tab=pending&page=3"); + let captured: any; + + function Probe() { + const [state, setState] = useListState("comments-moderation", schema); + captured = { state, setState }; + return null; + } + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + captured.setState({ page: 3 }); + }); + + expect(router.setSearchParams).not.toHaveBeenCalled(); + }); + it("does not push history when the update is a no-op", async () => { let captured: any; const router = await renderHook((value) => { diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts index 400689a6..c867901a 100644 --- a/packages/stack/src/client/hooks/use-list-state.ts +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -54,6 +54,15 @@ interface PendingUpdate { // only runs in client event handlers, never during SSR. let pendingQueue: PendingUpdate[] | null = null; +function searchParamsEqual(a: URLSearchParams, b: URLSearchParams): boolean { + const sorted = (params: URLSearchParams) => { + const copy = new URLSearchParams(params.toString()); + copy.sort(); + return copy.toString(); + }; + return sorted(a) === sorted(b); +} + function enqueueListStateUpdate(update: PendingUpdate): void { if (pendingQueue) { pendingQueue.push(update); @@ -71,15 +80,25 @@ function enqueueListStateUpdate(update: PendingUpdate): void { let params = currentParams; let replace = true; + let changed = false; for (const entry of queue) { - const nextState = { - ...parseListStateFromSearchParams( - entry.namespace, - entry.schema, - params, - ), - ...entry.patch, - }; + const prevState = parseListStateFromSearchParams( + entry.namespace, + entry.schema, + params, + ); + const nextState = { ...prevState, ...entry.patch }; + // Compare parsed states, not serialized strings: the URL may hold an + // explicit default (`?tab=pending`) or differ only in param order, and + // such state-identical updates must not touch history. + if ( + Object.keys(entry.schema).every((key) => + Object.is(prevState[key], nextState[key]), + ) + ) { + continue; + } + changed = true; params = serializeListStateToSearchParams( entry.namespace, entry.schema, @@ -99,9 +118,10 @@ function enqueueListStateUpdate(update: PendingUpdate): void { } } - // No-op updates (merged state already matches the URL) must not create - // history entries. - if (params.toString() === currentParams.toString()) return; + // Also skip when later entries reverted earlier ones back to the URL's + // current state (net no-op across the batch). Compare order-insensitively: + // serialization may reorder params without changing state. + if (!changed || searchParamsEqual(params, currentParams)) return; first.setSearchParams(params, { replace }); for (const entry of queue) entry.onFlushed(); }); From 12a23138867ab04b5019eed4b08fe2e98be96434 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:48:55 +0000 Subject: [PATCH 7/7] fix: notify all useListState instances when any instance flushes a URL update Replace per-instance urlVersion state with a module-level store consumed via useSyncExternalStore. Previously only the instances that called setState re-rendered after a flush, so sibling hooks sharing query keys could serve stale state when the router binding does not re-render on search-param changes (e.g. the Next.js preset). The popstate listener moves into the shared store subscription. Part of #133 Co-authored-by: Cursor --- .../src/__tests__/use-list-state.test.tsx | 35 ++++++++++++ .../stack/src/client/hooks/use-list-state.ts | 57 +++++++++++++------ 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/packages/stack/src/__tests__/use-list-state.test.tsx b/packages/stack/src/__tests__/use-list-state.test.tsx index fdf81859..cf905d51 100644 --- a/packages/stack/src/__tests__/use-list-state.test.tsx +++ b/packages/stack/src/__tests__/use-list-state.test.tsx @@ -186,6 +186,41 @@ describe("useListState", () => { expect(capturedB.state).toEqual({ folder: "photos" }); }); + it("re-renders sibling instances that did not call setState", async () => { + const router = createMockRouter(); + let capturedA: any; + let capturedB: any; + + // Two instances of the same list state (e.g. a toolbar and a pager + // rendered in separate subtrees). Only A updates; B must still see it. + function ProbeA() { + const [state, setState] = useListState("comments-moderation", schema); + capturedA = { state, setState }; + return null; + } + function ProbeB() { + const [state] = useListState("comments-moderation", schema); + capturedB = { state }; + return null; + } + + await act(async () => { + root.render( + + + + , + ); + }); + + await act(async () => { + capturedA.setState({ page: 5 }); + }); + + expect(capturedA.state.page).toBe(5); + expect(capturedB.state.page).toBe(5); + }); + it("treats state-identical updates as no-ops even when the URL holds explicit defaults", async () => { // URL contains `tab=pending` (the default) explicitly: serializing the // same state would drop it and change the query string without changing diff --git a/packages/stack/src/client/hooks/use-list-state.ts b/packages/stack/src/client/hooks/use-list-state.ts index c867901a..b180844a 100644 --- a/packages/stack/src/client/hooks/use-list-state.ts +++ b/packages/stack/src/client/hooks/use-list-state.ts @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import { useStackOrNull } from "../../context/provider"; import type { InferListState, ListStateSchema } from "../../shared/list-state"; import { @@ -41,7 +41,39 @@ interface PendingUpdate { next: URLSearchParams, opts?: { replace?: boolean }, ) => void; - onFlushed: () => void; +} + +// All useListState instances subscribe to this module-level store so that a +// URL flush triggered by ANY instance re-renders every mounted hook — not just +// the ones that called setState. Needed because router bindings may commit URL +// changes without re-rendering unrelated subtrees (e.g. the Next.js preset). +let urlVersion = 0; +const urlListeners = new Set<() => void>(); + +function notifyUrlChanged(): void { + urlVersion++; + for (const listener of urlListeners) listener(); +} + +function subscribeToUrl(listener: () => void): () => void { + if (urlListeners.size === 0 && typeof window !== "undefined") { + window.addEventListener("popstate", notifyUrlChanged); + } + urlListeners.add(listener); + return () => { + urlListeners.delete(listener); + if (urlListeners.size === 0 && typeof window !== "undefined") { + window.removeEventListener("popstate", notifyUrlChanged); + } + }; +} + +function getUrlVersion(): number { + return urlVersion; +} + +function getServerUrlVersion(): number { + return 0; } // Same-tick updates from ALL useListState instances are coalesced into one @@ -123,7 +155,7 @@ function enqueueListStateUpdate(update: PendingUpdate): void { // serialization may reorder params without changing state. if (!changed || searchParamsEqual(params, currentParams)) return; first.setSearchParams(params, { replace }); - for (const entry of queue) entry.onFlushed(); + notifyUrlChanged(); }); } @@ -155,19 +187,11 @@ export function useListState( const getSearchParams = router?.getSearchParams; const setSearchParams = router?.setSearchParams; - const [urlVersion, bumpUrlVersion] = useState(0); - - useEffect(() => { - if (typeof window === "undefined") return; - const onPopState = () => bumpUrlVersion((v) => v + 1); - window.addEventListener("popstate", onPopState); - return () => window.removeEventListener("popstate", onPopState); - }, []); - - // Read search params on every render so router-driven URL changes are picked - // up even when `getSearchParams` is referentially stable. `urlVersion` covers - // back/forward when the parent does not re-render. - void urlVersion; + // Every instance re-renders whenever any instance flushes a URL update (or + // on back/forward), so siblings sharing query keys never serve stale state. + // Search params are re-read on every render, so router-driven changes are + // also picked up even when `getSearchParams` is referentially stable. + useSyncExternalStore(subscribeToUrl, getUrlVersion, getServerUrlVersion); const state = parseListStateFromSearchParams( namespace, schema, @@ -203,7 +227,6 @@ export function useListState( explicitReplace: options?.replace, getSearchParams, setSearchParams, - onFlushed: () => bumpUrlVersion((v) => v + 1), }); }, [namespace, schema, setSearchParams, getSearchParams],