From 59da2f03ae6e4f59790393de69592d55f1109fa3 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sat, 19 Sep 2026 08:40:27 +0300 Subject: [PATCH] feat: deliver declarative store toolkit architecture --- CHANGELOG.md | 14 + README.md | 581 +++++++++--------- ...0001-declarative-resolved-store-toolkit.md | 38 ++ package.json | 8 +- pnpm-lock.yaml | 58 +- ...ed-package.mjs => check-packed-package.ts} | 130 ++-- scripts/package-consumer.ssr.ts | 68 ++ scripts/package-consumer.typecheck.ts | 64 +- scripts/publication-contract.ts | 50 ++ src/core/__tests__/createShallowStore.test.ts | 20 +- .../__tests__/createStoreToolkit.ssr.test.tsx | 101 +++ .../__tests__/createStoreToolkit.test.tsx | 327 +++++----- src/core/createShallowStore.ts | 18 +- src/core/createStoreToolkit.ts | 103 ++-- src/core/index.ts | 3 +- src/hooks/createResolvedStoreHooks.ts | 14 +- src/hooks/index.ts | 3 +- .../storeSelection/storeSelection.types.ts | 2 +- src/index.ts | 7 +- .../__tests__/createStoreProvider.test.tsx | 89 ++- src/providers/createStoreProvider.tsx | 200 ++---- src/providers/index.ts | 6 +- .../middlewareCompatibility.type-test.ts | 47 +- .../resolvedStoreContract.type-test.ts | 11 +- src/types/index.ts | 20 +- src/types/store-bindings.types.ts | 16 - src/types/store-provider.types.ts | 32 - src/types/store-toolkit.types.ts | 14 - src/types/store.types.ts | 5 + tsconfig.scripts.json | 18 + 30 files changed, 1158 insertions(+), 909 deletions(-) create mode 100644 docs/adr/0001-declarative-resolved-store-toolkit.md rename scripts/{check-packed-package.mjs => check-packed-package.ts} (61%) create mode 100644 scripts/package-consumer.ssr.ts create mode 100644 scripts/publication-contract.ts create mode 100644 src/core/__tests__/createStoreToolkit.ssr.test.tsx delete mode 100644 src/types/store-bindings.types.ts delete mode 100644 src/types/store-provider.types.ts delete mode 100644 src/types/store-toolkit.types.ts create mode 100644 tsconfig.scripts.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d74e26..118c23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Changes + +- Made the toolkit Resolved-store-first: `Provider`, `useStore`, `useStorePlain`, and + `useStoreApi()` now form the declarative top-level interface. +- Moved explicit Global access to `global.useStore`, `global.useStorePlain`, and `global.store`. +- Changed toolkit and Provider construction to typed input recipes with required Provider `input`. +- Renamed advanced optional Provider lookup to `useProviderStoreOptional`. +- Renamed the standalone Global Store handle from `useStoreApi` to `store` because it is a stable + property rather than a React hook. +- Removed `provider`, `getProvider()`, redundant Resolved hook names, and deprecated React 19 helper + exports from the package contract. + ### Added - Added packed-package verification for ESM, CommonJS, declarations, runtime dependencies, and side-effect safety. +- Added request-local SSR isolation and matching-input hydration verification. +- Added a typed publication inventory and a script-specific TypeScript configuration. ### Changed diff --git a/README.md b/README.md index e43dbed..cc235d8 100644 --- a/README.md +++ b/README.md @@ -4,101 +4,114 @@ [![npm downloads](https://img.shields.io/npm/dm/@okyrychenko-dev/react-zustand-toolkit.svg)](https://www.npmjs.com/package/@okyrychenko-dev/react-zustand-toolkit) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) -> Type-safe Zustand helpers for shallow-first selectors, isolated providers, and hooks that resolve between global and scoped stores. +Type-safe Zustand helpers for declarative Provider scope, shallow-first selection, SSR isolation, +and middleware-preserving Store handles. -## What This Library Does +## What this library does -`react-zustand-toolkit` gives you three composable layers: +`react-zustand-toolkit` provides four composable layers: -- `createShallowStore` for a global singleton store with shallow-first selectors -- `createStoreProvider` for isolated store instances in React context -- `createStoreToolkit` for both patterns together, plus resolved hooks that work inside and outside a provider +- `createStoreToolkit` for the normal declarative path: Resolved selection, explicit Global access, + and isolated Provider stores from one typed recipe +- `createShallowStore` for a standalone process-wide Store with Shallow-first selectors +- `createStoreProvider` for standalone isolated Provider stores +- `createResolvedStoreHooks` for advanced composition of an existing Global store and Provider -For custom integrations, `createResolvedStoreHooks` is also available to compose -resolved hooks from an existing global store and an optional context-store hook. - -It does not ship its own DevTools runtime for providers. -If you want Zustand Redux DevTools, apply `devtools(...)` in the store creator itself. +The library does not ship its own Redux DevTools or persistence runtime. Apply Zustand middleware in +the Store creator returned by the recipe. ## Features -- Shallow-first selectors with explicit plain-selector hooks -- Context providers with isolated store instances -- Resolved hooks that choose context store first and fall back to global store -- Optional custom equality for shallow-first selector hooks -- Full TypeScript inference with Zustand middleware support - -## Store Access Matrix - -Each factory uses the same value/plain/API naming pattern: - -| Store source | Shallow-first value | Plain value | Store API | -| ------------ | ------------------- | ----------------------- | --------------------- | -| Global | `useStore` | `useStorePlain` | `useStoreApi` | -| Provider | `useContextStore` | `useContextStorePlain` | `useContextStoreApi` | -| Resolved | `useResolvedValue` | `useResolvedStorePlain` | `useResolvedStoreApi` | - -The provider factory also exposes `useContextStoreOptional` for integrations that -need to detect whether a matching provider is present. The toolkit exposes its -shared provider bindings through `provider`. The deprecated `getProvider()` -compatibility accessor returns that same object and remains available until the -next intentional major release. +- Declarative nearest-Provider resolution with Global fallback +- Shallow-first and Plain selector modes, plus custom equality +- Typed creation-only Provider input for deterministic initialization +- Request-local SSR isolation and matching-input hydration +- Stable imperative Store handles with Zustand middleware capabilities +- Standalone factories for advanced composition ## Installation ```bash -pnpm add @okyrychenko-dev/react-zustand-toolkit zustand +pnpm add @okyrychenko-dev/react-zustand-toolkit react zustand ``` -## Quick Start +## Declarative toolkit + +Provider placement declares which Store descendants observe. The top-level hooks use the nearest +matching Provider store and otherwise fall back to the process-scoped Global store. ```tsx import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit"; +interface CounterInput { + initialCount: number; +} + interface CounterStore { count: number; increment: () => void; - decrement: () => void; } -const counterToolkit = createStoreToolkit( - (set) => ({ - count: 0, +export const counter = createStoreToolkit( + ({ initialCount }) => (set) => ({ + count: initialCount, increment: () => set((state) => ({ count: state.count + 1 })), - decrement: () => set((state) => ({ count: state.count - 1 })), }), - { name: "Counter" } + { globalInput: { initialCount: 0 }, name: "Counter" } ); -export const { - useStore: useCounterStore, - useResolvedValue: useCounter, - useResolvedStoreApi: useCounterStoreApi, -} = counterToolkit; - -export const { Provider: CounterProvider } = counterToolkit.provider; - -function Counter() { - const count = useCounter((state) => state.count); - const increment = useCounter((state) => state.increment); - - return ; +function Count() { + const count = counter.useStore((state) => state.count); + return {count}; } function App() { return ( - - - + + + ); } ``` -## Which Factory To Use +Outside a Provider, `Count` observes `counter.global.store`. Inside the Provider it observes `10`. +A nested `counter.Provider` wins for its descendants. Each Provider owns an isolated Store. + +### Toolkit interface + +| Member | Behavior | +| --- | --- | +| `Provider` | Creates one isolated Provider store from required, inert `input` | +| `useStore` | Resolved Shallow selection; accepts optional custom equality | +| `useStorePlain` | Resolved selection with Zustand's default equality | +| `useStoreApi()` | Hook returning the Resolved Store handle | +| `global.useStore` | Explicit Global Shallow selection | +| `global.useStorePlain` | Explicit Global Plain selection | +| `global.store` | Stable imperative Global Store handle; not a hook | + +The Store recipe runs synchronously when a Store is created and must be deterministic for equivalent +input and free of external side effects. It constructs state, actions, and middleware together; +the toolkit does not patch initial state afterward. Later Provider `input` changes are ignored. Use +Store actions for live changes or change the Provider `key` to start a new Store lifetime. + +`onStoreInit` runs synchronously after construction and before descendants observe the Store. +`onStoreReady` runs after commit at most once for a Provider Store. Put subscriptions, analytics, +registrations, and other external effects in `onStoreReady`, not in the recipe or `onStoreInit`. + +To model optional initialization, include `undefined` in the input type and still pass the required +`input` prop. + +## Choosing a factory + +### `createStoreToolkit` + +Use the declarative toolkit for most applications. Components use one top-level selection vocabulary +and Provider placement determines whether they observe a local Store or the Global fallback. The +complete example above shows its interface. ### `createShallowStore` -Use this when you want a global singleton store. +Use this when a process-wide Store is intentional and Provider isolation is unnecessary. ```tsx import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit"; @@ -108,303 +121,284 @@ interface SessionStore { setToken: (token: string | null) => void; } -const { useStore, useStorePlain, useStoreApi } = createShallowStore((set) => ({ +const session = createShallowStore((set) => ({ token: null, setToken: (token) => set({ token }), })); -const token = useStore((state) => state.token); -const plainToken = useStorePlain((state) => state.token); -const storeApi = useStoreApi; +const token = session.useStore((state) => state.token); +const plainToken = session.useStorePlain((state) => state.token); +session.store.getState().setToken("token"); ``` -Returns: - -- `useStore` -- `useStorePlain` -- `useStoreApi` +It returns `useStore`, `useStorePlain`, and the stable imperative `store` property. ### `createStoreProvider` -Use this when every provider instance must own a separate store. +Use this advanced factory when every Provider must own an isolated Store but you do not want Global +fallback or the combined toolkit interface. ```tsx import { createStoreProvider } from "@okyrychenko-dev/react-zustand-toolkit"; +interface WizardInput { + initialStep: number; +} + interface WizardStore { step: number; next: () => void; } -const { - Provider: WizardProvider, - useContextStore, - useContextStoreApi, -} = createStoreProvider( - (set) => ({ - step: 1, +const wizard = createStoreProvider( + ({ initialStep }) => (set) => ({ + step: initialStep, next: () => set((state) => ({ step: state.step + 1 })), }), "Wizard" ); function WizardStep() { - const step = useContextStore((state) => state.step); + const step = wizard.useContextStore((state) => state.step); return
Step {step}
; } function WizardShell() { return ( - + - + ); } ``` -Returns: +It returns `Provider`, strict Store hooks, `useIsInsideProvider`, and the advanced +`useProviderStoreOptional` lookup. Strict hooks throw outside the matching Provider. -- `Provider` -- `useContextStoreApi` -- `useContextStore` -- `useContextStorePlain` -- `useContextStoreOptional` -- `useIsInsideProvider` +## Provider lifecycle -### `createStoreToolkit` +Both the toolkit Provider and standalone Provider support two lifecycle stages: -Use this when components should work both with and without a provider. +- `onStoreInit` runs synchronously once during Store creation, before descendants observe it. +- `onStoreReady` runs after commit, at most once for that Store lifetime. -```tsx -import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit"; +Use the Store recipe and `onStoreInit` only for synchronous construction and validation. Start +subscriptions, analytics, registrations, and other external effects in `onStoreReady`. Changing +callback identities does not repeat a lifecycle stage that has already completed. + +## Server rendering and hydration + +> Never put request-specific server data in `global.store`. The Global store is module-scoped and +> can be shared by concurrent requests. A request-local Provider is the SSR isolation boundary. -interface CartStore { - items: string[]; - addItem: (item: string) => void; +Decode transport data before passing it to the Provider. Serialize that same data into the response +and give the client Provider observably equivalent decoded input on its first render. + +```tsx +interface PageInput { + userId: string; } -const cartToolkit = createStoreToolkit( - (set) => ({ - items: [], - addItem: (item) => set((state) => ({ items: [...state.items, item] })), - }), - { name: "Cart" } +const page = createStoreToolkit( + ({ userId }) => () => ({ userId }), + { globalInput: { userId: "anonymous" }, name: "Page" } ); -export const { useResolvedValue: useCart } = cartToolkit; -export const { Provider: CartProvider } = cartToolkit.provider; +function PageView() { + return
{page.useStore((state) => state.userId)}
; +} -function CartCount() { - const items = useCart((state) => state.items); - return {items.length}; +// Server: create one element tree per request. +const input = decodePageInput(request); +const html = renderToString( + + + +); +const serializedInput = JSON.stringify(input).replaceAll("<", "\\u003c"); + +// Client: decode the embedded data and hydrate with equivalent input. +const clientInput = decodeEmbeddedPageInput(serializedInput); +const root = document.getElementById("root"); +if (root) { + hydrateRoot( + root, + + + + ); } ``` -Returns: +The application owns encoding, decoding, and validation. React may construct and discard unobserved +Stores while retrying server work; the guarantee is that distinct request trees do not share an +observable Provider store, not an exact recipe call count. -- `useStore` -- `useStorePlain` -- `useStoreApi` -- `provider` -- `getProvider()` (deprecated; use `provider`) -- `useResolvedStoreApi()` -- `useResolvedValue()` -- `useResolvedStorePlain()` +### Next.js App Router -## Selector Semantics +Create the toolkit in a client module containing `"use client"`. A React Server Component may load, +validate, and serialize data, then pass inert props to a client component that renders the Provider. +Server Components must not invoke toolkit hooks or read or mutate toolkit Store handles. No Next.js +runtime dependency is required. -### Shallow-first mode +### Persistence -`useStore`, `useContextStore`, and `useResolvedValue` keep the previous selected value when the equality check passes. -By default they use `zustand/shallow`. -The selected reference also remains stable across parent re-renders. If a -resolved hook switches between its global and provider store, its cached value is -reset for the newly selected store. +Browser storage can change the first client state before hydration and cause markup mismatches. When +using Zustand persistence, disable automatic hydration when needed, render from the server input, +then rehydrate storage in a controlled post-commit step. Do not let persisted browser state replace +the initial Provider state before React hydrates matching server markup. -This is useful for object and array picks: +## Advanced standalone composition + +The standalone factories remain available when custom wiring is genuinely required: ```tsx -const selection = useCounter((state) => ({ - count: state.count, - increment: state.increment, -})); +const global = createShallowStore(recipe({ initialCount: 0 })); +const provider = createStoreProvider(recipe, "Counter"); +const resolved = createResolvedStoreHooks(global.store, provider.useProviderStoreOptional); ``` -You can also provide your own equality function: +`createStoreProvider` also exposes strict `useContextStoreApi`, Shallow `useContextStore`, Plain +`useContextStorePlain`, `useIsInsideProvider`, and advanced `useProviderStoreOptional`. Strict hooks +throw outside the matching Provider. Middleware-enhanced Store capabilities remain on all Store +handles. + +## Migration to the declarative major interface + +| Previous member | Replacement | +| --- | --- | +| `toolkit.provider.Provider` | `toolkit.Provider` | +| `toolkit.getProvider().Provider` | `toolkit.Provider` | +| `toolkit.provider.useContextStore` | `toolkit.useStore` for Resolved selection; `createStoreProvider().useContextStore` for standalone strict selection | +| `toolkit.provider.useContextStorePlain` | `toolkit.useStorePlain` for Resolved selection; `createStoreProvider().useContextStorePlain` for standalone strict selection | +| `toolkit.provider.useContextStoreApi` | `toolkit.useStoreApi()` for Resolved access; `createStoreProvider().useContextStoreApi()` for standalone strict access | +| `toolkit.provider.useIsInsideProvider` | Usually remove; Provider placement now declares scope. For advanced detection use `createStoreProvider().useIsInsideProvider()` | +| `toolkit.provider.useContextStoreOptional` | Omitted from the toolkit; use advanced `createStoreProvider().useProviderStoreOptional()` | +| `toolkit.getProvider()` | Remove and use the direct toolkit members above | +| `toolkit.useResolvedValue` | `toolkit.useStore` | +| `toolkit.useResolvedStorePlain` | `toolkit.useStorePlain` | +| `toolkit.useResolvedStoreApi()` | `toolkit.useStoreApi()` | +| `toolkit.useStore` | `toolkit.global.useStore` | +| `toolkit.useStorePlain` | `toolkit.global.useStorePlain` | +| `toolkit.useStoreApi` | `toolkit.global.store` | +| `createShallowStore().useStoreApi` | `createShallowStore().store` | +| `provider.useContextStoreOptional` | `provider.useProviderStoreOptional` | +| `createTransitionAction` | Call the Store action inside React's `startTransition` | +| `useActionStateAdapter` | Compose React's `useActionState` directly with the Store action | +| `useOptimisticReducer` | Compose React's `useOptimistic` directly with the Store update | + +Change a state creator into an input recipe, provide `globalInput`, add `input` to every Provider, +and move normal selection to the toolkit's top-level hooks. Outside a Provider they fall back to the +Global store; inside nested Providers they select the nearest store. Move request data from Global +state to request-local Provider input and use identical decoded input for server render and initial +client hydration. + +A codemod is intentionally not provided: the old `useStore` name becomes +`global.useStore` while the new top-level `useStore` has Resolved semantics, so automated renaming +cannot reliably distinguish destructured aliases and application-specific wrappers. Apply the table +manually and review each call by intended Store scope. + +Lifecycle ordering stays explicit during migration: ```tsx -const stableUser = useCounter( - (state) => state.user, - (left, right) => left?.id === right?.id -); + { + // Synchronous, before descendants observe this Store. Do not start external effects here. + validateInitialState(store.getState()); + }} + onStoreReady={(store) => { + // Post-commit, at most once per Store lifetime: connect external integrations here. + analytics.observe(store); + }} +> + + ``` -### Plain mode - -If you want standard Zustand selector behavior, use the explicit plain hooks: +## Selection semantics -```tsx -const value = useStorePlain((state) => state.value); -const contextValue = useContextStorePlain((state) => state.value); -const resolvedValue = useResolvedStorePlain((state) => state.value); -``` +Shallow hooks retain the prior selected reference while `zustand/shallow` (or supplied custom +equality) considers the next value equal. The cache is discarded when the selected Store identity +changes. Plain hooks use Zustand's default equality. Omitting a selector returns the full state. -## Resolved Hooks +### Shallow-first selection -Resolved hooks prefer the provider store when the component is inside a matching provider. -Otherwise they fall back to the global store. +Shallow selection is useful for object and array picks because an equivalent result retains its +previous reference: ```tsx -const toolkit = createStoreToolkit((set) => ({ - value: 0, - increment: () => set((state) => ({ value: state.value + 1 })), +const selection = counter.useStore((state) => ({ + count: state.count, + increment: state.increment, })); - -const { useResolvedValue, useResolvedStoreApi } = toolkit; - -function Status() { - const value = useResolvedValue((state) => state.value); - const store = useResolvedStoreApi(); - - return ; -} ``` -### `createResolvedStoreHooks` - -Use this lower-level factory when you already own the global store and provider -integration, but still need hooks that select the provider store when present and -otherwise use the global store. Most applications should use -`createStoreToolkit`, which configures this for you. +Supply custom equality when the domain has a stronger equivalence rule: ```tsx -import { - createResolvedStoreHooks, - createShallowStore, - createStoreProvider, -} from "@okyrychenko-dev/react-zustand-toolkit"; - -interface PreferencesStore { - theme: "light" | "dark"; -} - -const { useStoreApi } = createShallowStore(() => ({ - theme: "light", -})); - -const { useContextStoreOptional } = createStoreProvider( - () => ({ - theme: "light", - }), - "Preferences" +const stableUser = account.useStore( + (state) => state.user, + (left, right) => left?.id === right?.id ); - -const { useResolvedValue } = createResolvedStoreHooks(useStoreApi, useContextStoreOptional); - -function ThemeLabel() { - const theme = useResolvedValue((state) => state.theme); - - return {theme}; -} ``` -It returns the same resolved hook family used by `createStoreToolkit`: - -- `useResolvedStoreApi()` -- `useResolvedValue()` -- `useResolvedStorePlain()` - -## Upgrading After Deprecated Alias Removal - -The deprecated compatibility names have been removed. Replace them with their -canonical equivalents: - -| Removed name | Replacement | -| -------------------------------- | --------------------------- | -| `toolkit.createProvider()` | `toolkit.provider` | -| `useContext()` | `useContextStoreApi()` | -| `useOptionalContext()` | `useContextStoreOptional()` | -| `useResolvedStore()` | `useResolvedStoreApi()` | -| `useResolvedStoreWithSelector()` | `useResolvedValue()` | -| `onStoreCreate` | `onStoreReady` | - -`getProvider()` remains available as a deprecated compatibility path. Replace -`toolkit.getProvider()` with `toolkit.provider`; both currently return the exact -same Provider bindings object. Its removal is deferred to a separately approved -major release, and calling it does not emit a runtime warning. - -`onStoreReady` runs after the provider commits and at most once for each provider -store instance. Use `onStoreInit` when state must be initialized synchronously -during store creation. +Retained selection never crosses Store identities. Moving a component into or out of a Provider, or +between nested Providers, discards the old Store's cached value. -## Provider Lifecycle +### Plain selection -`createStoreProvider` supports two lifecycle stages: - -- `onStoreInit` for synchronous initialization during store creation -- `onStoreReady` for post-commit side effects - -`onStoreReady` is called at most once for each provider store instance. It may be -provided after the initial render and will run after that render commits, as long -as no ready callback has already run for the instance. +Use the explicit Plain hooks for Zustand's default selector behavior: ```tsx -const { Provider } = createStoreProvider((set) => ({ - ready: false, - setReady: (ready: boolean) => set({ ready }), -})); - - { - store.getState().setReady(true); - }} - onStoreReady={(store) => { - console.log("store mounted", store.getState()); - }} -> - -; +const resolvedValue = counter.useStorePlain((state) => state.count); +const globalValue = counter.global.useStorePlain((state) => state.count); +const providerValue = wizard.useContextStorePlain((state) => state.step); ``` -## Middleware Support +## Middleware support -Zustand middleware belongs in the store creator. -That includes Redux DevTools support. +Zustand middleware belongs in the Store creator returned by a recipe. Middleware-enhanced +capabilities remain available on Global, Provider, and Resolved Store handles. ```tsx -import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit"; +import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit"; import { devtools, persist } from "zustand/middleware"; -interface CounterStore { - count: number; - increment: () => void; -} - -const { useStore, useStoreApi } = createShallowStore< - CounterStore, - [["zustand/persist", CounterStore], ["zustand/devtools", never]] ->( - persist( - devtools( - (set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - }), - { name: "CounterStore" } +type CounterMutators = [ + ["zustand/persist", CounterStore], + ["zustand/devtools", never], +]; + +const counter = createStoreToolkit( + ({ initialCount }) => + persist( + devtools( + (set) => ({ + count: initialCount, + increment: () => set((state) => ({ count: state.count + 1 })), + }), + { name: "CounterStore" } + ), + { name: "counter-store" } ), - { name: "counter-store" } - ) + { globalInput: { initialCount: 0 } } ); -useStoreApi.persist.rehydrate(); -useStoreApi.devtools.cleanup(); +counter.global.store.persist.rehydrate(); +counter.global.store.devtools.cleanup(); ``` -This library does not auto-connect provider instances to Redux DevTools. +This library does not auto-connect Provider stores to Redux DevTools. Apply `devtools(...)` in the +recipe when each Store instance should expose that middleware capability. + +With browser persistence, also follow the controlled hydration guidance above so stored state does +not change the initial client render before React hydration completes. -## TypeScript +## TypeScript and subscriptions -The toolkit is designed to preserve store API types when you use Zustand middleware. +Store mutator types are preserved through the public Store handles. For example, +`subscribeWithSelector` retains its selector-aware subscription overload: ```tsx import { createShallowStore } from "@okyrychenko-dev/react-zustand-toolkit"; @@ -415,53 +409,38 @@ interface FilterStore { setQuery: (query: string) => void; } -const { useStoreApi } = createShallowStore( +const filter = createShallowStore( subscribeWithSelector((set) => ({ query: "", setQuery: (query) => set({ query }), })) ); -const unsubscribe = useStoreApi.subscribe( +const unsubscribe = filter.store.subscribe( (state) => state.query, - (nextQuery) => { - console.log(nextQuery); - } + (nextQuery) => console.log(nextQuery) ); unsubscribe(); ``` -## Migrating from the Deprecated React 19 Helpers +## Replacing the removed React 19 helpers -`createTransitionAction`, `useActionStateAdapter`, and `useOptimisticReducer` -remain available for compatibility until the next intentional major release. -They emit no runtime warnings. New code should compose React's supported -primitives directly. +The old helpers were thin wrappers around React primitives and are no longer package exports. -Replace `createTransitionAction` with `startTransition`. Return an asynchronous -action's promise from the transition scope so React keeps the transition pending -until the action settles: +Use `startTransition` instead of `createTransitionAction`: ```tsx import { startTransition } from "react"; function incrementInTransition(): void { startTransition(() => { - counterToolkit.useStoreApi.getState().increment(); - }); -} - -function saveInTransition(): void { - startTransition(async () => { - await save(); - counterToolkit.useStoreApi.setState({ saved: true }); + counter.global.store.getState().increment(); }); } ``` -Replace `useActionStateAdapter` with `useActionState`. Define the reducer from -the current `action` during each render so a re-render uses the latest action: +Use `useActionState` directly instead of `useActionStateAdapter`: ```tsx import { useActionState } from "react"; @@ -472,30 +451,18 @@ const [status, submit, isPending] = useActionState( ); ``` -Replace `useOptimisticReducer` with `useOptimistic` and dispatch optimistic -updates within a transition: +Use `useOptimistic` directly instead of `useOptimisticReducer`: ```tsx -import { startTransition, useCallback, useOptimistic } from "react"; - -const [optimisticTodos, dispatchOptimisticTodo] = useOptimistic(todos, (current, nextTodo) => [ - ...current, - nextTodo, -]); - -const addOptimisticTodo = useCallback( - (todo: Todo) => { - startTransition(() => dispatchOptimisticTodo(todo)); - }, - [dispatchOptimisticTodo] +import { startTransition, useOptimistic } from "react"; + +const [optimisticTodos, addOptimisticTodo] = useOptimistic( + todos, + (current, nextTodo: Todo) => [...current, nextTodo] ); -``` -These helpers are generic React primitive compositions rather than Zustand Store -selection or composition interfaces. Removing them leaves short, direct React -calls instead of spreading domain complexity across consumers. Equivalent thin -wrappers should not be reintroduced unless a future Store-specific requirement -creates a deeper interface. +startTransition(() => addOptimisticTodo(todo)); +``` ## Development diff --git a/docs/adr/0001-declarative-resolved-store-toolkit.md b/docs/adr/0001-declarative-resolved-store-toolkit.md new file mode 100644 index 0000000..486fde1 --- /dev/null +++ b/docs/adr/0001-declarative-resolved-store-toolkit.md @@ -0,0 +1,38 @@ +# ADR 0001: Make Resolved store behavior the toolkit default + +## Status + +Accepted + +## Context + +Callers previously had to understand separate Global, Provider, and Resolved hook families plus the +optional Provider-store lookup used to assemble them. That exposed the toolkit's wiring rather than +hiding it. It also made the module-scoped Global store look suitable for request-specific server +data even though it is process-scoped. + +## Decision + +`createStoreToolkit` is a declarative, Resolved-store-first interface. Provider placement declares +scope. Top-level hooks select the nearest matching Provider store and fall back to the Global store. +Explicit Global access lives under `global`. + +Toolkit creation receives one synchronous Store recipe. The recipe converts typed, +transport-decoded input into the complete Zustand state creator. The Global store is created from +`globalInput`; each Provider creates an isolated store from its required `input` prop. Input is +creation-only. Readiness effects begin after commit. + +The Global store is process-scoped and must not hold request-specific server data. Provider stores +are the request-local SSR seam. React Server Components do not invoke hooks or access Store handles; +they pass serialized data to client components that render Providers. + +Standalone factories remain public for advanced composition. Optional lookup is named +`useProviderStoreOptional` and remains available only from `createStoreProvider`. + +## Consequences + +- Normal consumers learn one selection vocabulary and use React tree placement for scope. +- Retained selections reset when the Resolved store changes. +- Server requests remain isolated when each request renders its own Provider tree. +- Equivalent decoded server and client input is required for hydration parity. +- The old toolkit names are removed in the intentional major-version interface cut. diff --git a/package.json b/package.json index 329ff13..84e9e05 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "build": "tsup", "dev": "tsup --watch", "clean": "rm -rf dist", - "package:check": "node scripts/check-packed-package.mjs && attw --pack . && publint", + "package:check": "node scripts/check-packed-package.ts && attw --pack . && publint", "release:check": "pnpm run check && pnpm run package:check", "prepublishOnly": "pnpm run clean && pnpm run build", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json", "lint": "eslint src scripts --ext .ts,.tsx,.mjs", "lint:fix": "eslint src --ext .ts,.tsx --fix", "check": "pnpm run lint && pnpm run format:check && pnpm run typecheck && pnpm run test:run && pnpm run build", @@ -81,6 +81,7 @@ "@testing-library/react": "^16.3.0", "@types/node": "^24.10.4", "@types/react": "^19.2.5", + "@types/react-dom": "^19.3.0", "@typescript-eslint/eslint-plugin": "^8.46.4", "@typescript-eslint/parser": "^8.46.4", "@vitest/coverage-v8": "^4.1.11", @@ -95,7 +96,8 @@ "husky": "^9.1.7", "prettier": "^3.6.2", "publint": "^0.3.24", - "react": "^19.2.0", + "react": "^19.2.3", + "react-dom": "19.2.3", "tsup": "^8.5.1", "typescript": "^5.3.3", "typescript-eslint": "^8.46.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1c1304..37f41ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,13 +23,16 @@ importers: version: 6.10.0(@testing-library/dom@10.4.2) '@testing-library/react': specifier: ^16.3.0 - version: 16.3.3(@testing-library/dom@10.4.2)(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/node': specifier: ^24.10.4 version: 24.13.4 '@types/react': specifier: ^19.2.5 version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) '@typescript-eslint/eslint-plugin': specifier: ^8.46.4 version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) @@ -73,8 +76,11 @@ importers: specifier: ^0.3.24 version: 0.3.24 react: - specifier: ^19.2.0 - version: 19.3.0 + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.28)(supports-color@7.2.0)(typescript@5.9.3) @@ -89,7 +95,7 @@ importers: version: 4.1.11(@types/node@24.13.4)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.14.5)(vite@8.3.0(@types/node@24.13.4)(esbuild@0.27.7)) zustand: specifier: ^5.0.8 - version: 5.0.15(@types/react@19.3.0)(react@19.3.0) + version: 5.0.15(@types/react@19.3.0)(react@19.2.3) packages: @@ -738,6 +744,11 @@ packages: '@types/node@24.13.4': resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==} + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} @@ -1983,10 +1994,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - react-dom@19.3.0: - resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: - react: ^19.3.0 + react: ^19.2.3 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1994,8 +2005,8 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react@19.3.0: - resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} readdirp@4.1.2: @@ -2057,8 +2068,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - scheduler@0.28.0: - resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -2959,14 +2970,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.2 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) optionalDependencies: '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) '@types/aria-query@5.0.4': {} @@ -2987,6 +2999,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + '@types/react@19.3.0': dependencies: csstype: 3.2.3 @@ -4407,16 +4423,16 @@ snapshots: punycode@2.3.1: {} - react-dom@19.3.0(react@19.3.0): + react-dom@19.2.3(react@19.2.3): dependencies: - react: 19.3.0 - scheduler: 0.28.0 + react: 19.2.3 + scheduler: 0.27.0 react-is@16.13.1: {} react-is@17.0.2: {} - react@19.3.0: {} + react@19.2.3: {} readdirp@4.1.2: {} @@ -4536,7 +4552,7 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - scheduler@0.28.0: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4978,7 +4994,7 @@ snapshots: zod@4.6.5: {} - zustand@5.0.15(@types/react@19.3.0)(react@19.3.0): + zustand@5.0.15(@types/react@19.3.0)(react@19.2.3): optionalDependencies: '@types/react': 19.3.0 - react: 19.3.0 + react: 19.2.3 diff --git a/scripts/check-packed-package.mjs b/scripts/check-packed-package.ts similarity index 61% rename from scripts/check-packed-package.mjs rename to scripts/check-packed-package.ts index 2f39f42..87cbd41 100644 --- a/scripts/check-packed-package.mjs +++ b/scripts/check-packed-package.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; +import { publicationContract } from "./publication-contract.ts"; const repositoryRoot = new URL("../", import.meta.url); const repositoryPath = fileURLToPath(repositoryRoot); @@ -21,7 +22,7 @@ const tarballPath = join(temporaryRoot, "react-zustand-toolkit.tgz"); const extractRoot = join(temporaryRoot, "extract"); const consumerRoot = join(temporaryRoot, "consumer"); -function run(command, args) { +function run(command: string, args: Array) { return execFileSync(command, args, { cwd: repositoryRoot, encoding: "utf8", @@ -29,25 +30,35 @@ function run(command, args) { }); } -function invariant(condition, message) { +const contractViolations: Array = []; + +function check(condition: boolean, message: string) { if (!condition) { - throw new Error(message); + contractViolations.push(message); } } -function listFiles(directory) { +function throwIfContractViolations(): void { + if (contractViolations.length > 0) { + throw new Error( + `Publication contract violations (${contractViolations.length}):\n- ${contractViolations.join("\n- ")}` + ); + } +} + +function listFiles(directory: string): Array { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); return entry.isDirectory() ? listFiles(path) : [relative(join(extractRoot, "package"), path)]; }); } -function packageRootName(specifier) { +function packageRootName(specifier: string): string { const segments = specifier.split("/"); return specifier.startsWith("@") ? `${segments[0]}/${segments[1]}` : segments[0]; } -function externalPackages(contents) { +function externalPackages(contents: string): Array { const specifiers = [ ...contents.matchAll(/\bfrom\s+["']([^"']+)["']/gu), ...contents.matchAll(/\brequire\(["']([^"']+)["']\)/gu), @@ -55,6 +66,15 @@ function externalPackages(contents) { return [...new Set(specifiers)].sort(); } +function runtimeExportAssertions(moduleLabel: string): string { + const expectedRuntimeExports = [ + ...publicationContract.runtimeExports.stable, + ...publicationContract.runtimeExports.deprecated, + ].sort(); + + return `const expected = ${JSON.stringify(expectedRuntimeExports)};\nconst actual = Object.keys(packageExports).sort();\nif (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(\`${moduleLabel} exports differ: \${JSON.stringify(actual)}\`);\nfor (const name of expected) if (typeof packageExports[name] !== "function") throw new Error(\`${moduleLabel} export unavailable: \${name}\`);\n`; +} + try { run("pnpm", ["pack", "--out", tarballPath]); mkdirSync(extractRoot, { recursive: true }); @@ -62,34 +82,25 @@ try { run("tar", ["-xzf", tarballPath, "-C", extractRoot]); const packageRoot = join(extractRoot, "package"); - const expectedFiles = [ - "CHANGELOG.md", - "LICENSE", - "README.md", - "dist/index.cjs", - "dist/index.cjs.map", - "dist/index.d.cts", - "dist/index.d.ts", - "dist/index.js", - "dist/index.js.map", - "package.json", - ]; const packedFiles = listFiles(packageRoot).sort(); - invariant( - JSON.stringify(packedFiles) === JSON.stringify(expectedFiles), + check( + JSON.stringify(packedFiles) === JSON.stringify(publicationContract.packedFiles), `Unexpected packed files:\n${packedFiles.join("\n")}` ); const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); - invariant(manifest.sideEffects === false, "Published package must remain side-effect free"); - invariant( + check( + manifest.sideEffects === publicationContract.sideEffects, + "Published package must remain side-effect free" + ); + check( JSON.stringify(manifest.peerDependencies) === - JSON.stringify({ react: "^18.0.0 || ^19.0.0", zustand: "^5.0.0" }), + JSON.stringify(publicationContract.peerDependencies), "Published peer dependencies do not match the public contract" ); - invariant( - JSON.stringify(manifest.dependencies) === - JSON.stringify({ "@okyrychenko-dev/type-utils": "^0.1.2" }), + check( + JSON.stringify(Object.keys(manifest.dependencies ?? {}).sort()) === + JSON.stringify(publicationContract.runtimeDependencies), "Published runtime dependencies do not match the implementation contract" ); @@ -104,65 +115,99 @@ try { ) ), ].sort(); - invariant( + check( JSON.stringify(importedRuntimePackages) === JSON.stringify(declaredRuntimePackages), `Built imports ${JSON.stringify(importedRuntimePackages)} do not match declared runtime packages ${JSON.stringify(declaredRuntimePackages)}` ); - for (const exportPath of [ + const exportTargets = [ manifest.exports["."].import.types, manifest.exports["."].import.default, manifest.exports["."].require.types, manifest.exports["."].require.default, - ]) { - invariant(existsSync(join(packageRoot, exportPath)), `Missing exported file: ${exportPath}`); + ]; + check( + JSON.stringify(exportTargets) === JSON.stringify(publicationContract.exportTargets), + `Package export targets do not match the publication contract: ${JSON.stringify(exportTargets)}` + ); + for (const exportPath of exportTargets) { + check(existsSync(join(packageRoot, exportPath)), `Missing exported file: ${exportPath}`); } for (const declarationFile of ["dist/index.d.ts", "dist/index.d.cts"]) { const declarations = readFileSync(join(packageRoot, declarationFile), "utf8"); - for (const deprecatedName of [ - "getProvider", - "createTransitionAction", - "useActionStateAdapter", - "useOptimisticReducer", - ]) { + const exportStatement = declarations.match(/^export \{ ([^}]+) \};$/mu); + if (exportStatement) { + const exportEntries = exportStatement[1].split(", "); + const actualTypeExports = exportEntries + .filter((entry) => entry.startsWith("type ")) + .map((entry) => entry.slice("type ".length)) + .sort(); + const expectedTypeExports = [ + ...publicationContract.typeExports.stable, + ...publicationContract.typeExports.deprecated, + ].sort(); + check( + JSON.stringify(actualTypeExports) === JSON.stringify(expectedTypeExports), + `${declarationFile} type exports ${JSON.stringify(actualTypeExports)} do not match ${JSON.stringify(expectedTypeExports)}` + ); + } else { + check(false, `${declarationFile} is missing its public export statement`); + } + const deprecatedExports = [ + ...publicationContract.runtimeExports.deprecated, + ...publicationContract.typeExports.deprecated, + ]; + for (const deprecatedName of deprecatedExports) { const declarationPattern = new RegExp( `/\\*\\*(?:(?!\\*/)[\\s\\S])*?@deprecated(?:(?!\\*/)[\\s\\S])*?\\*/\\s*(?:declare\\s+function\\s+)?${deprecatedName}\\b`, "u" ); - invariant( + check( declarationPattern.test(declarations), `${declarationFile} is missing deprecation guidance for ${deprecatedName}` ); } } + throwIfContractViolations(); + writeFileSync( join(consumerRoot, "package.json"), JSON.stringify({ private: true, type: "module" }) ); writeFileSync( join(consumerRoot, "esm.mjs"), - 'import { createResolvedStoreHooks, createShallowStore, createStoreProvider, createTransitionAction, useActionStateAdapter, useOptimisticReducer } from "@okyrychenko-dev/react-zustand-toolkit";\nfor (const exportedFunction of [createResolvedStoreHooks, createShallowStore, createStoreProvider, createTransitionAction, useActionStateAdapter, useOptimisticReducer]) {\n if (typeof exportedFunction !== "function") throw new Error("ESM export unavailable");\n}\nconst provider = createStoreProvider(() => ({}));\nif (typeof provider.useContextStoreOptional !== "function") throw new Error("ESM optional provider access unavailable");\n' + `import * as packageExports from "@okyrychenko-dev/react-zustand-toolkit";\n${runtimeExportAssertions("ESM")}` ); writeFileSync( join(consumerRoot, "cjs.cjs"), - 'const { createResolvedStoreHooks, createShallowStore, createStoreProvider, createTransitionAction, useActionStateAdapter, useOptimisticReducer } = require("@okyrychenko-dev/react-zustand-toolkit");\nfor (const exportedFunction of [createResolvedStoreHooks, createShallowStore, createStoreProvider, createTransitionAction, useActionStateAdapter, useOptimisticReducer]) {\n if (typeof exportedFunction !== "function") throw new Error("CommonJS export unavailable");\n}\nconst provider = createStoreProvider(() => ({}));\nif (typeof provider.useContextStoreOptional !== "function") throw new Error("CommonJS optional provider access unavailable");\n' + `const packageExports = require("@okyrychenko-dev/react-zustand-toolkit");\n${runtimeExportAssertions("CommonJS")}` ); writeFileSync( join(consumerRoot, "side-effect-entry.js"), 'const before = new Set(Reflect.ownKeys(globalThis));\nawait import("@okyrychenko-dev/react-zustand-toolkit");\nconst added = Reflect.ownKeys(globalThis).filter((key) => !before.has(key));\nif (added.length > 0) throw new Error(`Package added globals: ${added.join(", ")}`);\n' ); const typeConsumer = readFileSync(join(repositoryPath, "scripts/package-consumer.typecheck.ts")); + const ssrConsumer = readFileSync(join(repositoryPath, "scripts/package-consumer.ssr.ts")); writeFileSync(join(consumerRoot, "consumer.mts"), typeConsumer); writeFileSync(join(consumerRoot, "consumer.cts"), typeConsumer); + writeFileSync(join(consumerRoot, "ssr.mts"), ssrConsumer); const consumerModules = join(consumerRoot, "node_modules"); const installedPackage = join(consumerModules, "@okyrychenko-dev/react-zustand-toolkit"); mkdirSync(join(consumerModules, "@okyrychenko-dev"), { recursive: true }); mkdirSync(join(consumerModules, "@types"), { recursive: true }); cpSync(packageRoot, installedPackage, { recursive: true }); - for (const packageName of ["@okyrychenko-dev/type-utils", "@types/react", "react", "zustand"]) { + for (const packageName of [ + "@okyrychenko-dev/type-utils", + "@types/react", + "@types/react-dom", + "happy-dom", + "react", + "react-dom", + "zustand", + ]) { const target = join(repositoryPath, "node_modules", packageName); const link = join(consumerModules, packageName); mkdirSync(join(link, ".."), { recursive: true }); @@ -185,9 +230,11 @@ try { "ES2020", "consumer.mts", "consumer.cts", + "ssr.mts", ], { cwd: consumerRoot, stdio: "inherit" } ); + execFileSync("node", [join(consumerRoot, "ssr.mts")], { stdio: "inherit" }); const sideEffectBundlePath = join(consumerRoot, "side-effect-check.js"); execFileSync( @@ -207,12 +254,13 @@ try { encoding: "utf8", timeout: 5_000, }); - invariant( + check( sideEffectExecution.status === 0 && sideEffectExecution.stdout === "" && sideEffectExecution.stderr === "", `Importing the package produced observable behavior despite sideEffects: false\n${sideEffectExecution.stdout}${sideEffectExecution.stderr}` ); + throwIfContractViolations(); } finally { rmSync(temporaryRoot, { force: true, recursive: true }); } diff --git a/scripts/package-consumer.ssr.ts b/scripts/package-consumer.ssr.ts new file mode 100644 index 0000000..8b7b949 --- /dev/null +++ b/scripts/package-consumer.ssr.ts @@ -0,0 +1,68 @@ +import { createStoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; + +const toolkit = createStoreToolkit<{ requestId: string }, string>( + (requestId) => () => ({ requestId }), + { globalInput: "global" } +); + +function RequestId() { + return createElement( + "span", + null, + toolkit.useStore((state) => state.requestId) + ); +} + +const serverInput = "request"; +const serializedInput = JSON.stringify(serverInput); +const html = renderToString( + createElement(toolkit.Provider, { input: serverInput, children: createElement(RequestId) }) +); + +if (!html.includes("request") || html.includes("global")) { + throw new Error(`Packed SSR consumer did not resolve its request-local Provider: ${html}`); +} + +const window = new Window(); +Object.defineProperty(globalThis, "window", { configurable: true, value: window }); +Object.defineProperty(globalThis, "document", { + configurable: true, + value: window.document, +}); +Object.defineProperty(globalThis, "Element", { + configurable: true, + value: window.Element, +}); +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + value: true, +}); +const candidateContainer: unknown = window.document.createElement("div"); +if (!(candidateContainer instanceof Element)) { + throw new Error("SSR test environment did not create a DOM Element"); +} +const container = candidateContainer; +container.innerHTML = html; +const errors: Array = []; +const originalConsoleError = console.error; +console.error = (...messages: Array) => errors.push(messages); +const clientInput: string = JSON.parse(serializedInput); + +await act(async () => { + hydrateRoot( + container, + createElement(toolkit.Provider, { + input: clientInput, + children: createElement(RequestId), + }) + ); +}); + +console.error = originalConsoleError; +if (container.textContent !== "request" || errors.length > 0) { + throw new Error(`Packed hydration mismatch: ${errors.join("\n")}`); +} diff --git a/scripts/package-consumer.typecheck.ts b/scripts/package-consumer.typecheck.ts index 04c5181..d30d9e6 100644 --- a/scripts/package-consumer.typecheck.ts +++ b/scripts/package-consumer.typecheck.ts @@ -3,38 +3,54 @@ import { createShallowStore, createStoreProvider, createStoreToolkit, - createTransitionAction, - useActionStateAdapter, - useOptimisticReducer, } from "@okyrychenko-dev/react-zustand-toolkit"; -import type { StoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit"; +import { devtools } from "zustand/middleware"; +import type { StoreProviderProps, StoreToolkit } from "@okyrychenko-dev/react-zustand-toolkit"; + +interface CounterInput { + initialCount: number; +} interface CounterStore { count: number; increment: VoidFunction; } -const creator = (set: (state: Partial) => void): CounterStore => ({ - count: 0, - increment: () => set({ count: 1 }), -}); +const recipe = + ({ initialCount }: CounterInput) => + (set: (state: Partial) => void): CounterStore => ({ + count: initialCount, + increment: () => set({ count: initialCount + 1 }), + }); -const shallowStore = createShallowStore(creator); -const provider = createStoreProvider(creator, "Counter"); -const toolkit: StoreToolkit = createStoreToolkit(creator); -const canonicalProvider = toolkit.provider; -const compatibilityProvider = toolkit.getProvider(); -const resolved = createResolvedStoreHooks( - shallowStore.useStoreApi, - provider.useContextStoreOptional +const shallowStore = createShallowStore(recipe({ initialCount: 0 })); +const provider = createStoreProvider(recipe, "Counter"); +const toolkit: StoreToolkit = createStoreToolkit< + CounterStore, + CounterInput +>(recipe, { globalInput: { initialCount: 0 } }); +const providerProps: StoreProviderProps = { + children: "Counter", + input: { initialCount: 1 }, + onStoreReady: (store) => store.getState().increment(), +}; +const resolved = createResolvedStoreHooks(shallowStore.store, provider.useProviderStoreOptional); +const globalStore = toolkit.global.store; +const middlewareToolkit = createStoreToolkit< + CounterStore, + CounterInput, + [["zustand/devtools", never]] +>( + (input) => + devtools((set) => ({ + count: input.initialCount, + increment: () => set((state) => ({ count: state.count + 1 })), + })), + { globalInput: { initialCount: 0 } } ); +const devtoolsCleanup: VoidFunction = middlewareToolkit.global.store.devtools.cleanup; -void shallowStore; -void provider; -void toolkit; -void canonicalProvider; -void compatibilityProvider; +void providerProps; void resolved; -void createTransitionAction; -void useActionStateAdapter; -void useOptimisticReducer; +void globalStore; +void devtoolsCleanup; diff --git a/scripts/publication-contract.ts b/scripts/publication-contract.ts new file mode 100644 index 0000000..2eedf72 --- /dev/null +++ b/scripts/publication-contract.ts @@ -0,0 +1,50 @@ +export const publicationContract = { + runtimeExports: { + stable: [ + "createResolvedStoreHooks", + "createShallowStore", + "createStoreProvider", + "createStoreToolkit", + ], + deprecated: [], + }, + typeExports: { + stable: [ + "GlobalStoreBindings", + "MutatorsStateCreator", + "ResolvedStoreBindings", + "ShallowStoreBindings", + "SimpleStateCreator", + "StoreApiWithMutators", + "StoreMutatorTuple", + "StorePlainHook", + "StoreProviderConfig", + "StoreProviderProps", + "StoreProviderResult", + "StoreRecipe", + "StoreToolkit", + "StoreToolkitOptions", + "StoreValueHook", + ], + deprecated: [], + }, + packedFiles: [ + "CHANGELOG.md", + "LICENSE", + "README.md", + "dist/index.cjs", + "dist/index.cjs.map", + "dist/index.d.cts", + "dist/index.d.ts", + "dist/index.js", + "dist/index.js.map", + "package.json", + ], + runtimeDependencies: ["@okyrychenko-dev/type-utils"], + peerDependencies: { + react: "^18.0.0 || ^19.0.0", + zustand: "^5.0.0", + }, + exportTargets: ["./dist/index.d.ts", "./dist/index.js", "./dist/index.d.cts", "./dist/index.cjs"], + sideEffects: false, +}; diff --git a/src/core/__tests__/createShallowStore.test.ts b/src/core/__tests__/createShallowStore.test.ts index f19e755..0db94f4 100644 --- a/src/core/__tests__/createShallowStore.test.ts +++ b/src/core/__tests__/createShallowStore.test.ts @@ -10,37 +10,31 @@ interface TestStore { describe("createShallowStore", () => { it("should select and update the global store", () => { - const { useStore, useStoreApi } = createShallowStore((set) => ({ + const { useStore, store } = createShallowStore((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), })); const { result } = renderHook(() => useStore((state) => state.count)); - act(() => useStoreApi.getState().increment()); + act(() => store.getState().increment()); expect(result.current).toBe(1); - expect(useStoreApi.getState().count).toBe(1); + expect(store.getState().count).toBe(1); }); it("should preserve middleware-enhanced store capabilities", () => { const listener = vi.fn(); - const { useStoreApi } = createShallowStore< - TestStore, - [["zustand/subscribeWithSelector", never]] - >( + const { store } = createShallowStore( subscribeWithSelector((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), })) ); - expectTypeOf(useStoreApi.subscribe).toBeCallableWith( - (state: TestStore) => state.count, - listener - ); + expectTypeOf(store.subscribe).toBeCallableWith((state: TestStore) => state.count, listener); - const unsubscribe = useStoreApi.subscribe((state) => state.count, listener); - useStoreApi.getState().increment(); + const unsubscribe = store.subscribe((state) => state.count, listener); + store.getState().increment(); expect(listener).toHaveBeenCalledWith(1, 0); unsubscribe(); diff --git a/src/core/__tests__/createStoreToolkit.ssr.test.tsx b/src/core/__tests__/createStoreToolkit.ssr.test.tsx new file mode 100644 index 0000000..a04c06f --- /dev/null +++ b/src/core/__tests__/createStoreToolkit.ssr.test.tsx @@ -0,0 +1,101 @@ +import { PassThrough } from "node:stream"; +import { act } from "@testing-library/react"; +import { hydrateRoot } from "react-dom/client"; +import { renderToPipeableStream, renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { createStoreToolkit } from "../createStoreToolkit"; +import type { ReactNode } from "react"; + +interface RequestInput { + requestId: string; +} + +interface RequestStore { + requestId: string; +} + +function createRequestToolkit() { + return createStoreToolkit( + ({ requestId }) => + () => ({ requestId }), + { globalInput: { requestId: "client-global" }, name: "Request" } + ); +} + +describe("createStoreToolkit SSR", () => { + it("should isolate concurrent request-local Provider trees", async () => { + const toolkit = createRequestToolkit(); + + function RequestId(): ReactNode { + return {toolkit.useStore((state) => state.requestId)}; + } + + function renderRequest(requestId: string): Promise { + return new Promise((resolve, reject) => { + const output = new PassThrough(); + let html = ""; + output.setEncoding("utf8"); + output.on("data", (chunk: unknown) => { + if (typeof chunk !== "string") { + reject(new Error("Server stream emitted non-text output")); + return; + } + html += chunk; + }); + output.on("end", () => resolve(html)); + const { pipe } = renderToPipeableStream( + + + , + { + onAllReady: () => pipe(output), + onError: reject, + } + ); + }); + } + + const [first, second] = await Promise.all([ + renderRequest("request-a"), + renderRequest("request-b"), + ]); + + expect(first).toContain("request-a"); + expect(first).not.toContain("request-b"); + expect(second).toContain("request-b"); + expect(second).not.toContain("request-a"); + expect(toolkit.global.store.getState().requestId).toBe("client-global"); + }); + + it("should hydrate without mismatch diagnostics from equivalent decoded input", async () => { + const toolkit = createRequestToolkit(); + const input = { requestId: "serialized-request" }; + + function RequestId(): ReactNode { + return {toolkit.useStore((state) => state.requestId)}; + } + + function App(): ReactNode { + return ( + + + + ); + } + + const container = document.createElement("div"); + container.innerHTML = renderToString(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let root: ReturnType | undefined; + + act(() => { + root = hydrateRoot(container, ); + }); + + expect(container.textContent).toBe("serialized-request"); + expect(consoleError).not.toHaveBeenCalled(); + + act(() => root?.unmount()); + consoleError.mockRestore(); + }); +}); diff --git a/src/core/__tests__/createStoreToolkit.test.tsx b/src/core/__tests__/createStoreToolkit.test.tsx index 9a67ce5..78e72b7 100644 --- a/src/core/__tests__/createStoreToolkit.test.tsx +++ b/src/core/__tests__/createStoreToolkit.test.tsx @@ -1,203 +1,210 @@ -import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { createStore } from "zustand"; -import { createResolvedStoreHooks } from "../../hooks"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { createStoreToolkit } from "../createStoreToolkit"; import type { ReactNode } from "react"; -interface TestStore { - count: number; - increment: () => void; +interface CounterInput { + initialCount: number; } -describe("createStoreToolkit", () => { - it("should create complete toolkit", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); +interface CounterStore { + count: number; + increment: VoidFunction; +} - expect(toolkit.useStore).toBeDefined(); - expect(toolkit.useStorePlain).toBeDefined(); - expect(toolkit.useStoreApi).toBeDefined(); - expect(toolkit.provider).toBeDefined(); - expect(toolkit.useResolvedStoreApi).toBeDefined(); - expect(toolkit.useResolvedValue).toBeDefined(); - expect(toolkit.useResolvedStorePlain).toBeDefined(); - }); +function createCounterToolkit() { + return createStoreToolkit( + ({ initialCount }) => + (set) => ({ + count: initialCount, + increment: () => set((state) => ({ count: state.count + 1 })), + }), + { globalInput: { initialCount: 1 }, name: "Counter" } + ); +} - it("should expose the shared provider through all provider access paths", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); - // eslint-disable-next-line @typescript-eslint/no-deprecated -- Verifies the compatibility path. - expect(toolkit.provider).toBe(toolkit.getProvider()); +describe("createStoreToolkit", () => { + it("should expose resolved behavior at the top level and explicit Global behavior", () => { + const toolkit = createCounterToolkit(); + + expect(toolkit.Provider).toBeTypeOf("function"); + expect(toolkit.useStore).toBeTypeOf("function"); + expect(toolkit.useStorePlain).toBeTypeOf("function"); + expect(toolkit.useStoreApi).toBeTypeOf("function"); + expect(toolkit.global.store.getState().count).toBe(1); + expect(toolkit).not.toHaveProperty("provider"); + expect(toolkit).not.toHaveProperty("getProvider"); }); - it("should work with global store", () => { - const { useStore } = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), + it("should resolve to the Global store outside a Provider", () => { + const toolkit = createCounterToolkit(); + const { result } = renderHook(() => ({ + shallow: toolkit.useStore((state) => state.count), + plain: toolkit.useStorePlain((state) => state.count), + store: toolkit.useStoreApi(), })); - const { result } = renderHook(() => useStore((state) => state.count)); - expect(result.current).toBe(0); + expect(result.current).toEqual({ shallow: 1, plain: 1, store: toolkit.global.store }); + act(() => toolkit.global.store.getState().increment()); + expect(result.current.shallow).toBe(2); + expect(result.current.plain).toBe(2); }); - it("should resolve to global store when outside provider", () => { - const { useResolvedValue, useStoreApi } = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); - - const { result: resolvedResult } = renderHook(() => useResolvedValue((state) => state.count)); - - expect(resolvedResult.current).toBe(0); - - act(() => { - useStoreApi.getState().increment(); - }); + it("should synchronously resolve to the nearest Provider store from inert input", () => { + const toolkit = createCounterToolkit(); + let observedStore: ReturnType | null = null; - expect(resolvedResult.current).toBe(1); - }); - - it("should resolve to context store when inside provider", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); - const { Provider } = toolkit.provider; - - const wrapper = ({ children }: { children: ReactNode }) => {children}; + function Count({ label }: { label: string }): ReactNode { + observedStore = toolkit.useStoreApi(); + return {`${label}:${String(toolkit.useStore((state) => state.count))}`}; + } - const { result } = renderHook(() => toolkit.useResolvedValue((state) => state.count), { - wrapper, - }); + render( + + + + + + + ); - expect(result.current).toBe(0); + expect(screen.getByText("outer:2")).toBeInTheDocument(); + expect(screen.getByText("inner:3")).toBeInTheDocument(); + expect(observedStore).not.toBeNull(); + expect(toolkit.global.store.getState().count).toBe(1); }); - it("should keep global and provider stores independent", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); - const { Provider } = toolkit.provider; - - // Global store - const { result: globalResult } = renderHook(() => - toolkit.useResolvedValue((state) => state.count) + it("should reset Retained selection when Provider placement changes the Resolved store", () => { + const toolkit = createStoreToolkit<{ items: Array }, number>( + (initialItem) => () => ({ items: [initialItem] }), + { globalInput: 1 } ); + const selections: Array> = []; - // Provider store - const wrapper = ({ children }: { children: ReactNode }) => {children}; + function Selection(): ReactNode { + const items = toolkit.useStore( + (state) => state.items, + () => true + ); + selections.push(items); + return {items[0]}; + } - const { result: providerResult } = renderHook( - () => toolkit.useResolvedValue((state) => state.count), - { wrapper } + const { rerender } = render(); + const globalSelection = selections[selections.length - 1]; + rerender( + + + ); - // Both start at 0 - expect(globalResult.current).toBe(0); - expect(providerResult.current).toBe(0); - - // Increment global - act(() => { - toolkit.useStoreApi.getState().increment(); - }); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(selections[selections.length - 1]).not.toBe(globalSelection); - // Global changed, provider didn't - expect(globalResult.current).toBe(1); - expect(providerResult.current).toBe(0); + rerender(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(selections[selections.length - 1]).toBe(globalSelection); }); - it("should support custom equality in resolved selector hook", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); - const { result } = renderHook(() => - toolkit.useResolvedValue( - (state) => state.count, - () => true - ) - ); - - expect(result.current).toBe(0); - - act(() => { - toolkit.useStoreApi.getState().increment(); + it("should provide Shallow, Plain, custom-equality, and full-state selection", () => { + const toolkit = createStoreToolkit<{ items: Array }>(() => () => ({ items: [1, 2] }), { + globalInput: undefined, }); - - expect(result.current).toBe(0); - }); - - it("should expose plain resolved selector access", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 0, - increment: () => set((state) => ({ count: state.count + 1 })), - })); - const { result } = renderHook(() => toolkit.useResolvedStorePlain((state) => state.count)); - const { result: apiResult } = renderHook(() => toolkit.useResolvedStoreApi()); - - expect(result.current).toBe(0); - expect(apiResult.current).toBe(toolkit.useStoreApi); - }); - - it("should expose full resolved state without selector outside provider", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 3, - increment: () => set((state) => ({ count: state.count + 1 })), + const { result } = renderHook(() => ({ + shallow: toolkit.useStore((state) => state.items), + custom: toolkit.useStore( + (state) => state.items, + () => true + ), + plain: toolkit.useStorePlain((state) => state.items), + full: toolkit.useStore(), })); + const originalItems = result.current.shallow; + const replacementItems = [1, 2]; - const { result: valueResult } = renderHook(() => toolkit.useResolvedValue()); - const { result: plainResult } = renderHook(() => toolkit.useResolvedStorePlain()); + act(() => toolkit.global.store.setState({ items: replacementItems })); - expect(valueResult.current.count).toBe(3); - expect(typeof valueResult.current.increment).toBe("function"); - expect(plainResult.current.count).toBe(3); - expect(typeof plainResult.current.increment).toBe("function"); + expect(result.current.shallow).toBe(originalItems); + expect(result.current.custom).toBe(originalItems); + expect(result.current.plain).toBe(replacementItems); + expect(result.current.full.items).toBe(replacementItems); }); - it("should expose full resolved state from provider store", () => { - const toolkit = createStoreToolkit((set) => ({ - count: 7, - increment: () => set((state) => ({ count: state.count + 1 })), - })); + it("should consume input once and complete readiness once per Provider lifetime", async () => { + const toolkit = createCounterToolkit(); + const firstReady = vi.fn(); + const secondReady = vi.fn(); + const stores: Array> = []; - const { Provider } = toolkit.provider; - const wrapper = ({ children }: { children: ReactNode }) => {children}; + function Count(): ReactNode { + stores.push(toolkit.useStoreApi()); + return {toolkit.useStore((state) => state.count)}; + } - const { result: valueResult } = renderHook(() => toolkit.useResolvedValue(), { wrapper }); - const { result: plainResult } = renderHook(() => toolkit.useResolvedStorePlain(), { - wrapper, - }); + const { rerender } = render( + + + + ); + await waitFor(() => expect(firstReady).toHaveBeenCalledOnce()); - expect(valueResult.current.count).toBe(7); - expect(typeof valueResult.current.increment).toBe("function"); - expect(plainResult.current.count).toBe(7); - expect(typeof plainResult.current.increment).toBe("function"); + rerender( + + + + ); + + expect(screen.getByText("4")).toBeInTheDocument(); + expect(stores[stores.length - 1]).toBe(stores[0]); + expect(secondReady).not.toHaveBeenCalled(); }); - it("should reset resolved selection reference when the resolved store changes", () => { - interface CollectionStore { - items: Array; + it("should create a new Provider lifetime after a keyed remount", () => { + const toolkit = createCounterToolkit(); + function Count(): ReactNode { + return {toolkit.useStore((state) => state.count)}; } + const { rerender } = render( + + + + ); + rerender( + + + + ); + expect(screen.getByText("9")).toBeInTheDocument(); + }); - const globalItems = [1, 2, 3]; - const contextItems = [1, 2, 3]; - const globalStore = createStore(() => ({ items: globalItems })); - const contextStore = createStore(() => ({ items: contextItems })); - let resolvedContextStore: typeof contextStore | null = null; - const { useResolvedValue } = createResolvedStoreHooks(globalStore, () => resolvedContextStore); - const { result, rerender } = renderHook(() => useResolvedValue((state) => state.items)); - - expect(result.current).toBe(globalItems); + it("should propagate Store recipe and initialization errors through React", () => { + const recipeError = new Error("recipe failed"); + const toolkit = createStoreToolkit( + (shouldFail) => { + if (shouldFail) { + throw recipeError; + } + return () => ({ count: 0, increment: () => undefined }); + }, + { globalInput: false } + ); - resolvedContextStore = contextStore; - rerender(); + expect(() => render(unreachable)).toThrow( + recipeError + ); - expect(result.current).toBe(contextItems); + const initError = new Error("init failed"); + expect(() => + render( + { + throw initError; + }} + > + unreachable + + ) + ).toThrow(initError); }); }); diff --git a/src/core/createShallowStore.ts b/src/core/createShallowStore.ts index c57a23a..e72569b 100644 --- a/src/core/createShallowStore.ts +++ b/src/core/createShallowStore.ts @@ -2,11 +2,19 @@ import { createStore } from "zustand"; import { createStoreSelectionBindings } from "../hooks"; import type { MutatorsStateCreator, - ShallowStoreBindings, StoreApiWithMutators, StoreMutatorTuple, + StorePlainHook, + StoreValueHook, } from "../types"; +/** Store bindings with shallow comparison built in. */ +export interface ShallowStoreBindings = []> { + useStore: StoreValueHook; + useStorePlain: StorePlainHook; + store: StoreApiWithMutators; +} + /** * Creates a Zustand store with shallow-first selector semantics. * @@ -34,7 +42,7 @@ import type { * @returns Object with two properties: * - `useStore`: Hook to access store with shallow comparison * - `useStorePlain`: Hook with plain Zustand selector semantics - * - `useStoreApi`: Direct access to the store API for imperative usage + * - `store`: Direct access to the Store API for imperative usage * * @example * Basic counter store with shallow comparison @@ -111,10 +119,10 @@ import type { * @example * Using store API for imperative access * ```tsx - * const { useStore, useStoreApi } = createShallowStore(...); + * const { useStore, store } = createShallowStore(...); * * function ExternalButton() { - * const storeApi = useStoreApi; + * const storeApi = store; * * const handleClick = () => { * // Direct imperative access without hook @@ -148,6 +156,6 @@ export function createShallowStore = []>( - storeCreator: MutatorsStateCreator, - options: { - name?: string; - } = {} -): StoreToolkit { - const storeName = options.name ?? "Store"; - - // Create global singleton store - const { useStore, useStorePlain, useStoreApi } = createShallowStore( - storeCreator - ); - - // Create a single shared provider that will be reused - const provider = createStoreProvider(storeCreator, storeName); +/** Configuration for one declarative toolkit and its process-scoped Global store. */ +export interface StoreToolkitOptions { + globalInput: TInput; + name?: string; +} - // Returns shared provider context/hooks for this toolkit instance - function getProvider(): StoreProviderResult { - return provider; - } +/** Explicit access to the process-scoped Global store. */ +export interface GlobalStoreBindings = []> { + useStore: StoreValueHook; + useStorePlain: StorePlainHook; + store: StoreApiWithMutators; +} - // Use the shared provider's hooks for resolution - const { useContextStoreOptional } = provider; +/** Declarative Resolved-store-first toolkit. */ +export interface StoreToolkit< + TState, + TInput = undefined, + TMutators extends Array = [], +> { + Provider: (props: StoreProviderProps) => ReactNode; + useStore: StoreValueHook; + useStorePlain: StorePlainHook; + useStoreApi: () => StoreApiWithMutators; + global: GlobalStoreBindings; +} - // Create resolution hooks - const resolvedBindings = createResolvedStoreHooks(useStoreApi, useContextStoreOptional); +/** + * Creates a declarative toolkit whose top-level hooks resolve the nearest Provider store and + * otherwise fall back to the process-scoped Global store. + */ +export function createStoreToolkit< + TState, + TInput = undefined, + TMutators extends Array = [], +>( + storeRecipe: StoreRecipe, + { globalInput, name = "Store" }: StoreToolkitOptions +): StoreToolkit { + const globalBindings = createShallowStore(storeRecipe(globalInput)); + const provider = createStoreProvider(storeRecipe, name); + const resolved = createResolvedStoreHooks( + globalBindings.store, + provider.useProviderStoreOptional + ); return { - useStore, - useStorePlain, - useStoreApi, - provider, - getProvider, - ...resolvedBindings, + Provider: provider.Provider, + useStore: resolved.useResolvedValue, + useStorePlain: resolved.useResolvedStorePlain, + useStoreApi: resolved.useResolvedStoreApi, + global: { + useStore: globalBindings.useStore, + useStorePlain: globalBindings.useStorePlain, + store: globalBindings.store, + }, }; } diff --git a/src/core/index.ts b/src/core/index.ts index 6eeec53..605aef1 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,4 @@ export { createShallowStore } from "./createShallowStore"; export { createStoreToolkit } from "./createStoreToolkit"; -export type { ShallowStoreBindings, StoreToolkit } from "../types"; +export type { ShallowStoreBindings } from "./createShallowStore"; +export type { GlobalStoreBindings, StoreToolkit, StoreToolkitOptions } from "./createStoreToolkit"; diff --git a/src/hooks/createResolvedStoreHooks.ts b/src/hooks/createResolvedStoreHooks.ts index 5ace941..c3cadc6 100644 --- a/src/hooks/createResolvedStoreHooks.ts +++ b/src/hooks/createResolvedStoreHooks.ts @@ -1,5 +1,17 @@ import { createStoreSelectionBindings } from "./storeSelection"; -import type { ResolvedStoreBindings, StoreApiWithMutators, StoreMutatorTuple } from "../types"; +import type { + StoreApiWithMutators, + StoreMutatorTuple, + StorePlainHook, + StoreValueHook, +} from "../types"; + +/** Bindings that resolve to a Provider store when present, otherwise the Global store. */ +export interface ResolvedStoreBindings = []> { + useResolvedStoreApi: () => StoreApiWithMutators; + useResolvedValue: StoreValueHook; + useResolvedStorePlain: StorePlainHook; +} /** * Creates hooks that resolve between context store and global store diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 1ccb038..9a77945 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ export { createResolvedStoreHooks } from "./createResolvedStoreHooks"; -export type { ResolvedStoreBindings, StorePlainHook, StoreValueHook } from "../types"; +export type { ResolvedStoreBindings } from "./createResolvedStoreHooks"; +export type { StorePlainHook, StoreValueHook } from "../types/store-hooks.types"; export { createStoreSelectionBindings } from "./storeSelection"; export type { StoreResolver, StoreSelectionBindings } from "./storeSelection"; diff --git a/src/hooks/storeSelection/storeSelection.types.ts b/src/hooks/storeSelection/storeSelection.types.ts index 797132d..096ce03 100644 --- a/src/hooks/storeSelection/storeSelection.types.ts +++ b/src/hooks/storeSelection/storeSelection.types.ts @@ -1,5 +1,5 @@ import type { StoreApi } from "zustand"; -import type { StorePlainHook, StoreValueHook } from "../../types/store-hooks.types"; +import type { StorePlainHook, StoreValueHook } from "../../types"; export type SelectionStore = Pick< StoreApi, diff --git a/src/index.ts b/src/index.ts index 019e36c..95c2605 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,13 +7,10 @@ export { createStoreProvider } from "./providers"; // Hook utilities export { createResolvedStoreHooks } from "./hooks"; -// React 19 utilities -// eslint-disable-next-line @typescript-eslint/no-deprecated -- Preserves compatibility exports until the next major release. -export { createTransitionAction, useActionStateAdapter, useOptimisticReducer } from "./react19"; - // Types export type { MutatorsStateCreator, + GlobalStoreBindings, ResolvedStoreBindings, ShallowStoreBindings, SimpleStateCreator, @@ -23,6 +20,8 @@ export type { StoreProviderProps, StoreProviderResult, StoreMutatorTuple, + StoreRecipe, StoreToolkit, + StoreToolkitOptions, StoreValueHook, } from "./types"; diff --git a/src/providers/__tests__/createStoreProvider.test.tsx b/src/providers/__tests__/createStoreProvider.test.tsx index 129eeb0..55da16a 100644 --- a/src/providers/__tests__/createStoreProvider.test.tsx +++ b/src/providers/__tests__/createStoreProvider.test.tsx @@ -17,7 +17,7 @@ describe("createStoreProvider", () => { useContextStore, useContextStorePlain, useIsInsideProvider, - } = createStoreProvider((set) => ({ + } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); @@ -30,7 +30,7 @@ describe("createStoreProvider", () => { }); it("should throw error when used outside provider", () => { - const { useContextStoreApi } = createStoreProvider((set) => ({ + const { useContextStoreApi } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); @@ -41,12 +41,14 @@ describe("createStoreProvider", () => { }); it("should work inside provider", () => { - const { Provider, useContextStore } = createStoreProvider((set) => ({ + const { Provider, useContextStore } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); - const wrapper = ({ children }: PropsWithChildren) => {children}; + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); const { result } = renderHook(() => useContextStore((state) => state.value), { wrapper, @@ -56,7 +58,7 @@ describe("createStoreProvider", () => { }); it("should detect if inside provider", () => { - const { Provider, useIsInsideProvider } = createStoreProvider((set) => ({ + const { Provider, useIsInsideProvider } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); @@ -64,21 +66,27 @@ describe("createStoreProvider", () => { const { result: outsideResult } = renderHook(() => useIsInsideProvider()); expect(outsideResult.current).toBe(false); - const wrapper = ({ children }: PropsWithChildren) => {children}; + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); const { result: insideResult } = renderHook(() => useIsInsideProvider(), { wrapper }); expect(insideResult.current).toBe(true); }); it("should create isolated instances", () => { - const { Provider, useContextStore } = createStoreProvider((set) => ({ + const { Provider, useContextStore } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); - const wrapper1 = ({ children }: PropsWithChildren) => {children}; + const wrapper1 = ({ children }: PropsWithChildren) => ( + {children} + ); - const wrapper2 = ({ children }: PropsWithChildren) => {children}; + const wrapper2 = ({ children }: PropsWithChildren) => ( + {children} + ); const { result: result1 } = renderHook(() => useContextStore(), { wrapper: wrapper1 }); const { result: result2 } = renderHook(() => useContextStore(), { wrapper: wrapper2 }); @@ -94,13 +102,14 @@ describe("createStoreProvider", () => { it("should call onStoreReady callback with store", async () => { let receivedStore: StoreApi | null = null; - const { Provider, useContextStore } = createStoreProvider((set) => ({ + const { Provider, useContextStore } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); const wrapper = ({ children }: PropsWithChildren) => ( { receivedStore = store; }} @@ -125,7 +134,7 @@ describe("createStoreProvider", () => { setInitialized: (value: boolean) => void; } - const { Provider, useContextStore } = createStoreProvider((set) => ({ + const { Provider, useContextStore } = createStoreProvider(() => (set) => ({ value: 0, initialized: false, increment: () => set((state) => ({ value: state.value + 1 })), @@ -134,6 +143,7 @@ describe("createStoreProvider", () => { const wrapper = ({ children }: PropsWithChildren) => ( { store.getState().setInitialized(true); }} @@ -149,26 +159,28 @@ describe("createStoreProvider", () => { expect(result.current).toBe(true); }); - it("should return null from useContextStoreOptional when outside provider", () => { - const { useContextStoreOptional } = createStoreProvider((set) => ({ + it("should return null from useProviderStoreOptional when outside provider", () => { + const { useProviderStoreOptional } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); - const { result } = renderHook(() => useContextStoreOptional()); + const { result } = renderHook(() => useProviderStoreOptional()); expect(result.current).toBeNull(); }); - it("should return store from useContextStoreOptional when inside provider", () => { - const { Provider, useContextStoreOptional } = createStoreProvider((set) => ({ + it("should return store from useProviderStoreOptional when inside provider", () => { + const { Provider, useProviderStoreOptional } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); - const wrapper = ({ children }: PropsWithChildren) => {children}; + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); - const { result } = renderHook(() => useContextStoreOptional(), { wrapper }); + const { result } = renderHook(() => useProviderStoreOptional(), { wrapper }); expect(result.current).not.toBeNull(); expect(result.current).toHaveProperty("getState"); @@ -178,13 +190,14 @@ describe("createStoreProvider", () => { it("should call onStoreReady only once for rerenders", async () => { let calls = 0; - const { Provider, useContextStore } = createStoreProvider((set) => ({ + const { Provider, useContextStore } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); const wrapper = ({ children }: PropsWithChildren) => ( { calls += 1; }} @@ -207,15 +220,19 @@ describe("createStoreProvider", () => { it("should call onStoreReady when it is provided after the initial render", async () => { const onStoreReady = vi.fn(); - const { Provider } = createStoreProvider((set) => ({ + const { Provider } = createStoreProvider(() => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), })); - const { rerender } = render(content); + const { rerender } = render(content); expect(onStoreReady).not.toHaveBeenCalled(); - rerender(content); + rerender( + + content + + ); await waitFor(() => { expect(onStoreReady).toHaveBeenCalledOnce(); @@ -224,13 +241,15 @@ describe("createStoreProvider", () => { it("should support custom equality for context selector hook", () => { const { Provider, useContextStoreApi, useContextStore } = createStoreProvider( - (set) => ({ + () => (set) => ({ value: 0, increment: () => set((state) => ({ value: state.value + 1 })), }) ); - const wrapper = ({ children }: PropsWithChildren) => {children}; + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); const { result } = renderHook( () => ({ @@ -259,14 +278,16 @@ describe("createStoreProvider", () => { } const { Provider, useContextStore, useContextStorePlain, useContextStoreApi } = - createStoreProvider((set) => ({ + createStoreProvider(() => (set) => ({ value: 0, label: "test", increment: () => set((state) => ({ value: state.value + 1 })), setLabel: (label: string) => set({ label }), })); - const wrapper = ({ children }: PropsWithChildren) => {children}; + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); const { result } = renderHook( () => ({ api: useContextStoreApi(), @@ -289,13 +310,17 @@ describe("createStoreProvider", () => { label: string; } - const { Provider, useContextStorePlain } = createStoreProvider((set) => ({ - value: 5, - label: "ready", - increment: () => set((state) => ({ value: state.value + 1 })), - })); + const { Provider, useContextStorePlain } = createStoreProvider( + () => (set) => ({ + value: 5, + label: "ready", + increment: () => set((state) => ({ value: state.value + 1 })), + }) + ); - const wrapper = ({ children }: PropsWithChildren) => {children}; + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); const { result } = renderHook(() => useContextStorePlain(), { wrapper }); expect(result.current.value).toBe(5); diff --git a/src/providers/createStoreProvider.tsx b/src/providers/createStoreProvider.tsx index 10cfeff..a04e4f4 100644 --- a/src/providers/createStoreProvider.tsx +++ b/src/providers/createStoreProvider.tsx @@ -3,169 +3,73 @@ import { type ReactNode, createContext, useContext, useEffect, useRef, useState import { createStore } from "zustand"; import { createStoreSelectionBindings } from "../hooks"; import type { - MutatorsStateCreator, StoreApiWithMutators, StoreMutatorTuple, - StoreProviderProps, - StoreProviderResult, + StorePlainHook, + StoreRecipe, + StoreValueHook, } from "../types"; +/** Lifecycle callbacks for one Provider store. */ +export interface StoreProviderConfig< + TState = unknown, + TMutators extends Array = [], +> { + onStoreInit?: (store: StoreApiWithMutators) => void; + onStoreReady?: (store: StoreApiWithMutators) => void; +} + +/** Props for a generated Provider. */ +export interface StoreProviderProps< + TState = unknown, + TInput = undefined, + TMutators extends Array = [], +> extends StoreProviderConfig { + children: ReactNode; + input: TInput; +} + +/** Bindings returned by the standalone Provider-store factory. */ +export interface StoreProviderResult< + TState, + TInput = undefined, + TMutators extends Array = [], +> { + Provider: (props: StoreProviderProps) => ReactNode; + useContextStoreApi: () => StoreApiWithMutators; + useContextStore: StoreValueHook; + useContextStorePlain: StorePlainHook; + useIsInsideProvider: () => boolean; + useProviderStoreOptional: () => StoreApiWithMutators | null; +} + /** - * Creates a React Context provider for isolated Zustand store instances. - * - * This utility creates a React Context provider that wraps a Zustand store, allowing - * each provider instance to have its own isolated store. This is essential for: - * - **Server-Side Rendering**: Each request gets its own store instance - * - **Testing**: No shared state between test runs - * - **Micro-frontends**: Isolated state per application instance - * - **Multiple instances**: Same component tree with independent state - * - * The provider creates isolated store instances and provides hooks for accessing - * the store from components within the provider tree. - * - * Returns an object with: - * - `Provider`: React component to wrap your app - * - `useContextStoreApi`: Hook to get the store API - * - `useContextStore`: Hook to select values from store (with shallow comparison) - * - `useContextStorePlain`: Hook to select values with plain Zustand semantics - * - `useIsInsideProvider`: Hook to check if inside provider - * - `useContextStoreOptional`: Hook that returns null if outside provider - * - * @template TState - The shape of your store state and actions - * @template TMutators - Array of mutators (middleware) applied to the store (default: []) - * - * @param storeCreator - Function that creates the store state and actions - * @param contextName - Optional name for better debugging (default: 'Store') - * Used in React DevTools display names and error messages - * - * @returns Object with Provider component and hooks to access the context store - * - * @example - * Basic usage with provider - * ```tsx - * import { createStoreProvider } from '@okyrychenko-dev/react-zustand-toolkit'; - * - * interface TodoState { - * todos: Todo[]; - * addTodo: (text: string) => void; - * removeTodo: (id: string) => void; - * } - * - * const { Provider: TodoProvider, useContextStore: useTodoStore } = - * createStoreProvider( - * (set) => ({ - * todos: [], - * addTodo: (text) => set((state) => ({ - * todos: [...state.todos, { id: Date.now().toString(), text }] - * })), - * removeTodo: (id) => set((state) => ({ - * todos: state.todos.filter(t => t.id !== id) - * })), - * }), - * 'Todo' - * ); - * - * // Wrap your app - * function App() { - * return ( - * - * - * - * - * ); - * } - * - * // Use in components - * function TodoList() { - * const todos = useTodoStore((state) => state.todos); - * return
    {todos.map(todo =>
  • {todo.text}
  • )}
; - * } - * ``` - * - * @example - * Multiple independent instances - * ```tsx - * const { Provider: CounterProvider, useContextStore } = - * createStoreProvider(..., 'Counter'); - * - * function App() { - * return ( - *
- * - * - * - * - * - * - * - *
- * ); - * } - * - * // Each Counter has its own isolated state - * function Counter({ title }) { - * const { count, increment } = useContextStore(); - * return
{title}: {count}
; - * } - * ``` - * - * @example - * With lifecycle hooks - * ```tsx - * const { Provider } = createStoreProvider((set) => ({ - * // ... state - * })); - * - * { - * store.setState({ ready: true }); - * }} - * onStoreReady={(store) => { - * console.log('Store initialized:', store.getState()); - * }} - * > - * - * - * ``` - * - * @example - * Conditional rendering based on provider existence - * ```tsx - * const { Provider, useContextStore, useIsInsideProvider } = - * createStoreProvider(..., 'Settings'); - * - * function SettingsButton() { - * const isInsideSettingsProvider = useIsInsideProvider(); - * - * if (!isInsideSettingsProvider) { - * return null; // Don't render if not inside provider - * } - * - * return ; - * } - * ``` - * - * @see {@link createShallowStore} for creating a global store - * @see {@link https://github.com/pmndrs/zustand | Zustand documentation} + * Creates an advanced standalone Provider-store module. * - * @public - * @since 0.6.0 + * Each Provider synchronously creates one isolated Store from its required inert input. Input is + * consumed only at creation. `onStoreInit` completes before descendants observe the Store, while + * `onStoreReady` runs after commit at most once for the Store lifetime. */ -export function createStoreProvider = []>( - storeCreator: MutatorsStateCreator, +export function createStoreProvider< + TState, + TInput = undefined, + TMutators extends Array = [], +>( + storeRecipe: StoreRecipe, contextName = "Store" -): StoreProviderResult { +): StoreProviderResult { const StoreContext = createContext | null>(null); StoreContext.displayName = `${contextName}Context`; function Provider({ children, + input, onStoreInit, onStoreReady, - }: StoreProviderProps): ReactNode { + }: StoreProviderProps): ReactNode { const isReadyRef = useRef(false); const [store] = useState>(() => { - const newStore = createStore(storeCreator); + const newStore = createStore(storeRecipe(input)); onStoreInit?.(newStore); return newStore; }); @@ -195,7 +99,7 @@ export function createStoreProvider | null { + function useProviderStoreOptional(): StoreApiWithMutators | null { return useContext(StoreContext); } @@ -208,6 +112,6 @@ export function createStoreProvider ->; +type DevtoolsCleanupAssertion = Assert>; const _persistStore = createShallowStore( persist( @@ -51,7 +46,7 @@ const _persistStore = createShallowStore>; +type PersistApiAssertion = Assert>; const _subscribeStore = createShallowStore< CounterState, @@ -65,7 +60,7 @@ const _subscribeStore = createShallowStore< })) ); -const _unsubscribe = _subscribeStore.useStoreApi.subscribe( +const _unsubscribe = _subscribeStore.store.subscribe( (state) => state.count, (selected, previous) => { const nextCount: number = selected; @@ -88,9 +83,7 @@ const _immerStore = createShallowStore })) ); -const _immerUpdater: Parameters[0] = ( - draft: CounterState -) => { +const _immerUpdater: Parameters[0] = (draft: CounterState) => { draft.count += 1; }; @@ -111,13 +104,17 @@ const combinedCreator = persist(combinedCreator); -const _toolkit = createStoreToolkit(combinedCreator); +const _provider = createStoreProvider( + () => combinedCreator +); +const _toolkit = createStoreToolkit( + () => combinedCreator, + { globalInput: undefined } +); const _standaloneResolved = createResolvedStoreHooks( - _toolkit.useStoreApi, - _provider.useContextStoreOptional + _toolkit.global.store, + _provider.useProviderStoreOptional ); -const _toolkitResolvedContract: ResolvedStoreBindings = _toolkit; const _standaloneResolvedContract: ResolvedStoreBindings = _standaloneResolved; @@ -127,17 +124,13 @@ type ProviderPersistAssertion = Assert< type ProviderDevtoolsAssertion = Assert< HasKey, "devtools"> >; -type ToolkitPersistAssertion = Assert>; -type ToolkitDevtoolsAssertion = Assert>; -type ResolvedPersistAssertion = Assert< - HasKey, "persist"> ->; +type ToolkitPersistAssertion = Assert>; +type ToolkitDevtoolsAssertion = Assert>; +type ResolvedPersistAssertion = Assert, "persist">>; type StandaloneResolvedDevtoolsAssertion = Assert< HasKey, "devtools"> >; -void _toolkitResolvedContract; - export type MiddlewareCompatibilityAssertions = [ DevtoolsCleanupAssertion, PersistApiAssertion, diff --git a/src/type-tests/resolvedStoreContract.type-test.ts b/src/type-tests/resolvedStoreContract.type-test.ts index 83efec5..0b9cd10 100644 --- a/src/type-tests/resolvedStoreContract.type-test.ts +++ b/src/type-tests/resolvedStoreContract.type-test.ts @@ -1,5 +1,8 @@ import { createStore } from "zustand"; -import { type ResolvedStoreBindings, createResolvedStoreHooks, createStoreToolkit } from "../index"; +import { createStoreToolkit } from "../core"; +import { createResolvedStoreHooks } from "../hooks"; +import type { StoreToolkit } from "../core"; +import type { ResolvedStoreBindings } from "../hooks"; interface CounterState { count: number; @@ -18,8 +21,10 @@ const plainState: CounterState = resolvedBindings.useResolvedStorePlain(); const plainCount: number = resolvedBindings.useResolvedStorePlain((state) => state.count); const resolvedApi = resolvedBindings.useResolvedStoreApi(); -const toolkit = createStoreToolkit(() => ({ count: 0 })); -const toolkitBindings: ResolvedStoreBindings = toolkit; +const toolkit = createStoreToolkit(() => () => ({ count: 0 }), { + globalInput: undefined, +}); +const toolkitBindings: StoreToolkit = toolkit; void fullState; void selectedCount; diff --git a/src/types/index.ts b/src/types/index.ts index 6a60f5a..4af8f55 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,14 +1,20 @@ -export type { ResolvedStoreBindings, ShallowStoreBindings } from "./store-bindings.types"; +export type { ShallowStoreBindings } from "../core/createShallowStore"; +export type { + GlobalStoreBindings, + StoreToolkit, + StoreToolkitOptions, +} from "../core/createStoreToolkit"; +export type { ResolvedStoreBindings } from "../hooks/createResolvedStoreHooks"; +export type { + StoreProviderConfig, + StoreProviderProps, + StoreProviderResult, +} from "../providers/createStoreProvider"; export type { StorePlainHook, StoreValueHook } from "./store-hooks.types"; export type { MutatorsStateCreator, SimpleStateCreator, StoreApiWithMutators, + StoreRecipe, StoreMutatorTuple, } from "./store.types"; -export type { - StoreProviderConfig, - StoreProviderProps, - StoreProviderResult, -} from "./store-provider.types"; -export type { StoreToolkit } from "./store-toolkit.types"; diff --git a/src/types/store-bindings.types.ts b/src/types/store-bindings.types.ts deleted file mode 100644 index 10339d4..0000000 --- a/src/types/store-bindings.types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { StorePlainHook, StoreValueHook } from "./store-hooks.types"; -import type { StoreApiWithMutators, StoreMutatorTuple } from "./store.types"; - -/** Bindings that resolve to a provider store when present, otherwise the global store. */ -export interface ResolvedStoreBindings = []> { - useResolvedStoreApi: () => StoreApiWithMutators; - useResolvedValue: StoreValueHook; - useResolvedStorePlain: StorePlainHook; -} - -/** Store bindings with shallow comparison built in. */ -export interface ShallowStoreBindings = []> { - useStore: StoreValueHook; - useStorePlain: StorePlainHook; - useStoreApi: StoreApiWithMutators; -} diff --git a/src/types/store-provider.types.ts b/src/types/store-provider.types.ts deleted file mode 100644 index da5d541..0000000 --- a/src/types/store-provider.types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ReactNode } from "react"; -import type { StorePlainHook, StoreValueHook } from "./store-hooks.types"; -import type { StoreApiWithMutators, StoreMutatorTuple } from "./store.types"; - -/** Configuration for store provider lifecycle. */ -export interface StoreProviderConfig< - TState = unknown, - TMutators extends Array = [], -> { - /** Pure synchronous initialization invoked when the store instance is created. */ - onStoreInit?: (store: StoreApiWithMutators) => void; - /** Post-commit callback invoked at most once for each provider store instance. */ - onStoreReady?: (store: StoreApiWithMutators) => void; -} - -/** Props for a generated provider. */ -export interface StoreProviderProps< - TState = unknown, - TMutators extends Array = [], -> extends StoreProviderConfig { - children: ReactNode; -} - -/** Bindings returned by the store provider factory. */ -export interface StoreProviderResult = []> { - Provider: (props: StoreProviderProps) => ReactNode; - useContextStoreApi: () => StoreApiWithMutators; - useContextStore: StoreValueHook; - useContextStorePlain: StorePlainHook; - useIsInsideProvider: () => boolean; - useContextStoreOptional: () => StoreApiWithMutators | null; -} diff --git a/src/types/store-toolkit.types.ts b/src/types/store-toolkit.types.ts deleted file mode 100644 index 670c95f..0000000 --- a/src/types/store-toolkit.types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ResolvedStoreBindings, ShallowStoreBindings } from "./store-bindings.types"; -import type { StoreProviderResult } from "./store-provider.types"; -import type { StoreMutatorTuple } from "./store.types"; - -/** Combined global, provider, and resolved store bindings. */ -export interface StoreToolkit = []> - extends ShallowStoreBindings, ResolvedStoreBindings { - provider: StoreProviderResult; - /** - * @deprecated Use {@link provider} instead. This compatibility accessor will be removed in the - * next intentional major release. - */ - getProvider: () => StoreProviderResult; -} diff --git a/src/types/store.types.ts b/src/types/store.types.ts index dec158b..d29c724 100644 --- a/src/types/store.types.ts +++ b/src/types/store.types.ts @@ -15,3 +15,8 @@ export type MutatorsStateCreator< TState, TMutators extends Array = [], > = StateCreator; + +/** Creates a complete Store definition from inert, transport-decoded input. */ +export type StoreRecipe = []> = ( + input: TInput +) => MutatorsStateCreator; diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 0000000..f04eaac --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "baseUrl": ".", + "declaration": false, + "declarationMap": false, + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "paths": { + "@okyrychenko-dev/react-zustand-toolkit": ["./src/index.ts"] + }, + "rootDir": ".", + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "scripts/**/*.mts"] +}