@shopify/hydrogen 2026.10.1 → 2026.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +6 -3
- package/dist/client/client.mjs +2 -4
- package/dist/client/client.mjs.map +1 -1
- package/dist/client/i18n-variables.d.mts +13 -0
- package/dist/client/i18n-variables.mjs +84 -0
- package/dist/client/i18n-variables.mjs.map +1 -0
- package/dist/client/types.d.mts +13 -6
- package/dist/core/cart/cart.mjs +110 -23
- package/dist/core/cart/cart.mjs.map +1 -1
- package/dist/core/cart/queries.d.mts +57 -57
- package/dist/core/predictive-search/queries.d.mts +2 -2
- package/dist/core/request-context.mjs +1 -1
- package/dist/core/request-routing/interceptors/proxy.mjs.map +1 -1
- package/dist/core/request-routing/registered-routes.d.mts +5 -1
- package/dist/core/request-routing/registered-routes.mjs +21 -7
- package/dist/core/request-routing/registered-routes.mjs.map +1 -1
- package/dist/core/request-routing/route-types.d.mts +16 -6
- package/dist/core/shopify-scripts/perfkit.mjs +8 -2
- package/dist/core/shopify-scripts/perfkit.mjs.map +1 -1
- package/dist/core/utils/load-script.mjs +6 -6
- package/dist/core/utils/load-script.mjs.map +1 -1
- package/dist/customer-account/client.mjs +1 -1
- package/dist/customer-account/session.mjs +1 -1
- package/dist/development/client/client.mjs +2 -4
- package/dist/development/client/client.mjs.map +1 -1
- package/dist/development/client/i18n-variables.d.mts +13 -0
- package/dist/development/client/i18n-variables.mjs +84 -0
- package/dist/development/client/i18n-variables.mjs.map +1 -0
- package/dist/development/client/types.d.mts +13 -6
- package/dist/development/core/cart/cart.mjs +110 -23
- package/dist/development/core/cart/cart.mjs.map +1 -1
- package/dist/development/core/request-context.mjs +1 -1
- package/dist/development/core/request-routing/interceptors/proxy.mjs.map +1 -1
- package/dist/development/core/request-routing/registered-routes.d.mts +5 -1
- package/dist/development/core/request-routing/registered-routes.mjs +21 -7
- package/dist/development/core/request-routing/registered-routes.mjs.map +1 -1
- package/dist/development/core/request-routing/route-types.d.mts +16 -6
- package/dist/development/core/shopify-scripts/perfkit.mjs +8 -2
- package/dist/development/core/shopify-scripts/perfkit.mjs.map +1 -1
- package/dist/development/core/utils/load-script.mjs +6 -6
- package/dist/development/core/utils/load-script.mjs.map +1 -1
- package/dist/development/graphql/type-resolver.d.mts +27 -1
- package/dist/development/react/cart.mjs +79 -25
- package/dist/development/react/cart.mjs.map +1 -1
- package/dist/graphql/type-resolver.d.mts +27 -1
- package/dist/react/cart.mjs +79 -25
- package/dist/react/cart.mjs.map +1 -1
- package/dist/vite/index.d.mts +11 -1
- package/dist/vite/index.mjs +21 -1
- package/dist/vite/index.mjs.map +1 -0
- package/dist/vue/shopify-scripts.d.mts +1 -1
- package/package.json +1 -1
- package/skills/hydrogen-cart-ui/SKILL.md +2 -2
- package/skills/hydrogen-request-handlers/SKILL.md +2 -2
- package/skills/hydrogen-request-handlers/references/frameworks.md +2 -7
- package/skills/hydrogen-request-handlers/references/nuxt.md +1 -3
- package/skills/hydrogen-storefront-client/SKILL.md +4 -2
|
@@ -245,6 +245,32 @@ type InferResult<T extends string> = InferResultForSchema<T, StorefrontSchema>;
|
|
|
245
245
|
type InferVariables<T extends string> = InferVariablesForSchema<T, StorefrontSchema>;
|
|
246
246
|
/** The operation kind of a query string, such as `query`. Resolves to `unknown` unless the query text has exactly one operation. */
|
|
247
247
|
type InferOperationKind<T extends string> = parseDocument<T> extends infer Doc ? [Doc] extends [never] ? "unknown" : OperationKindType<Doc> : "unknown";
|
|
248
|
+
type DeclaredNameOfType<Definition, Types> = Definition extends {
|
|
249
|
+
variable: {
|
|
250
|
+
name: {
|
|
251
|
+
value: infer Name extends keyof Types;
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
type: infer Type;
|
|
255
|
+
} ? (Type extends {
|
|
256
|
+
kind: "NonNullType";
|
|
257
|
+
type: infer Inner;
|
|
258
|
+
} ? Inner : Type) extends {
|
|
259
|
+
kind: "NamedType";
|
|
260
|
+
name: {
|
|
261
|
+
value: Types[Name];
|
|
262
|
+
};
|
|
263
|
+
} ? Name : never : never;
|
|
264
|
+
/**
|
|
265
|
+
* Names of the first operation's variables that are declared with the named
|
|
266
|
+
* type `Types` maps them to, nullable or non-null. List types never match.
|
|
267
|
+
*/
|
|
268
|
+
type InferVariableNamesDeclaredAs<T extends string, Types extends Record<string, string>> = parseDocument<T> extends infer Doc ? Doc extends {
|
|
269
|
+
kind: "Document";
|
|
270
|
+
definitions: infer Defs extends readonly unknown[];
|
|
271
|
+
} ? FirstOperation<Defs> extends {
|
|
272
|
+
variableDefinitions: infer Vars extends readonly unknown[];
|
|
273
|
+
} ? DeclaredNameOfType<Vars[number], Types> : never : never : never;
|
|
248
274
|
//#endregion
|
|
249
|
-
export { GraphQLSchemaFor, InferOperationKind, InferResult, InferResultForSchema, InferVariables, InferVariablesForSchema };
|
|
275
|
+
export { GraphQLSchemaFor, InferOperationKind, InferResult, InferResultForSchema, InferVariableNamesDeclaredAs, InferVariables, InferVariablesForSchema };
|
|
250
276
|
//# sourceMappingURL=type-resolver.d.mts.map
|
package/dist/react/cart.mjs
CHANGED
|
@@ -50,9 +50,7 @@ function createCartComponents() {
|
|
|
50
50
|
return useOptionalCart((state) => selector(state), isEqual);
|
|
51
51
|
}
|
|
52
52
|
function useSuspenseCart(selector, isEqual) {
|
|
53
|
-
|
|
54
|
-
if (readyPromise) throw readyPromise;
|
|
55
|
-
return useCart(selector, isEqual);
|
|
53
|
+
return useSuspenseCartSelector(selector, isEqual);
|
|
56
54
|
}
|
|
57
55
|
return {
|
|
58
56
|
CartProvider: TypedCartProvider,
|
|
@@ -64,11 +62,14 @@ function createCartComponents() {
|
|
|
64
62
|
};
|
|
65
63
|
}
|
|
66
64
|
function useCartStore(hookName = "useCart") {
|
|
67
|
-
|
|
68
|
-
if (!store) throw new Error(`${hookName} must be used inside <CartProvider>.`);
|
|
69
|
-
return store;
|
|
65
|
+
return useCartContext(hookName).store;
|
|
70
66
|
}
|
|
71
|
-
function
|
|
67
|
+
function useCartContext(hookName = "useCart") {
|
|
68
|
+
const context = useContext(CartContext);
|
|
69
|
+
if (!context) throw new Error(`${hookName} must be used inside <CartProvider>.`);
|
|
70
|
+
return context;
|
|
71
|
+
}
|
|
72
|
+
function useOptionalCartContext() {
|
|
72
73
|
return useContext(CartContext);
|
|
73
74
|
}
|
|
74
75
|
/**
|
|
@@ -88,7 +89,14 @@ function useOptionalCartStore() {
|
|
|
88
89
|
* @publicDocs
|
|
89
90
|
*/
|
|
90
91
|
function CartProvider({ initialData, children }) {
|
|
91
|
-
const
|
|
92
|
+
const context = useMemo(() => {
|
|
93
|
+
const store = createCartStore({ initialData });
|
|
94
|
+
return {
|
|
95
|
+
store,
|
|
96
|
+
hydrationSnapshot: store.getState()
|
|
97
|
+
};
|
|
98
|
+
}, []);
|
|
99
|
+
const { store } = context;
|
|
92
100
|
useEffect(() => {
|
|
93
101
|
configureCartEndpoint(cartEndpoint);
|
|
94
102
|
store.connect();
|
|
@@ -97,7 +105,7 @@ function CartProvider({ initialData, children }) {
|
|
|
97
105
|
};
|
|
98
106
|
}, [store]);
|
|
99
107
|
return /* @__PURE__ */ jsx(CartContext.Provider, {
|
|
100
|
-
value:
|
|
108
|
+
value: context,
|
|
101
109
|
children
|
|
102
110
|
});
|
|
103
111
|
}
|
|
@@ -127,7 +135,8 @@ function CartProvider({ initialData, children }) {
|
|
|
127
135
|
* @publicDocs
|
|
128
136
|
*/
|
|
129
137
|
function useCart(selector, isEqual) {
|
|
130
|
-
|
|
138
|
+
const { store, hydrationSnapshot } = useCartContext();
|
|
139
|
+
return useCartSelector(store, hydrationSnapshot, selector, isEqual);
|
|
131
140
|
}
|
|
132
141
|
/**
|
|
133
142
|
* Returns the cart action that reloads the cart after a cart change outside cart forms.
|
|
@@ -194,32 +203,77 @@ function useCartAnalytics() {
|
|
|
194
203
|
* @publicDocs
|
|
195
204
|
*/
|
|
196
205
|
function useOptionalCart(selector, isEqual) {
|
|
197
|
-
const
|
|
198
|
-
if (!
|
|
199
|
-
return useCartSelector(store, selector, isEqual);
|
|
206
|
+
const context = useOptionalCartContext();
|
|
207
|
+
if (!context) return void 0;
|
|
208
|
+
return useCartSelector(context.store, context.hydrationSnapshot, selector, isEqual);
|
|
200
209
|
}
|
|
201
|
-
function useCartSelector(store, selector, isEqual) {
|
|
202
|
-
const
|
|
210
|
+
function useCartSelector(store, hydrationSnapshot, selector, isEqual) {
|
|
211
|
+
const liveCacheRef = useRef(null);
|
|
212
|
+
const hydrationCacheRef = useRef(null);
|
|
213
|
+
const getSnapshot = () => selectCartSnapshot(store.getState(), selector, isEqual, liveCacheRef);
|
|
214
|
+
const getServerSnapshot = () => selectCartSnapshot(hydrationSnapshot, selector, isEqual, hydrationCacheRef);
|
|
215
|
+
return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
|
|
216
|
+
}
|
|
217
|
+
function selectCartSnapshot(state, selector, isEqual, cacheRef) {
|
|
218
|
+
if (cacheRef.current?.state === state && cacheRef.current.selector === selector) return cacheRef.current.value;
|
|
219
|
+
const next = selector(state);
|
|
220
|
+
if (cacheRef.current && isEqual?.(cacheRef.current.value, next)) {
|
|
221
|
+
cacheRef.current = {
|
|
222
|
+
state,
|
|
223
|
+
selector,
|
|
224
|
+
value: cacheRef.current.value
|
|
225
|
+
};
|
|
226
|
+
return cacheRef.current.value;
|
|
227
|
+
}
|
|
228
|
+
cacheRef.current = {
|
|
229
|
+
state,
|
|
230
|
+
selector,
|
|
231
|
+
value: next
|
|
232
|
+
};
|
|
233
|
+
return next;
|
|
234
|
+
}
|
|
235
|
+
function useSuspenseCartSelector(selector, isEqual) {
|
|
236
|
+
const { store } = useCartContext();
|
|
237
|
+
const cacheRef = useRef(null);
|
|
203
238
|
const getSnapshot = () => {
|
|
204
239
|
const state = store.getState();
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
if (cacheRef.current?.state === state && cacheRef.current.selector === selector) return cacheRef.current.snapshot;
|
|
241
|
+
if (state.readyPromise) {
|
|
242
|
+
const snapshot = {
|
|
243
|
+
status: "pending",
|
|
244
|
+
promise: state.readyPromise
|
|
245
|
+
};
|
|
246
|
+
cacheRef.current = {
|
|
209
247
|
state,
|
|
210
248
|
selector,
|
|
211
|
-
|
|
249
|
+
snapshot
|
|
212
250
|
};
|
|
213
|
-
return
|
|
251
|
+
return snapshot;
|
|
214
252
|
}
|
|
215
|
-
|
|
253
|
+
const value = selector(state);
|
|
254
|
+
const previous = cacheRef.current?.snapshot;
|
|
255
|
+
if (previous?.status === "ready" && isEqual?.(previous.value, value)) {
|
|
256
|
+
cacheRef.current = {
|
|
257
|
+
state,
|
|
258
|
+
selector,
|
|
259
|
+
snapshot: previous
|
|
260
|
+
};
|
|
261
|
+
return previous;
|
|
262
|
+
}
|
|
263
|
+
const snapshot = {
|
|
264
|
+
status: "ready",
|
|
265
|
+
value
|
|
266
|
+
};
|
|
267
|
+
cacheRef.current = {
|
|
216
268
|
state,
|
|
217
269
|
selector,
|
|
218
|
-
|
|
270
|
+
snapshot
|
|
219
271
|
};
|
|
220
|
-
return
|
|
272
|
+
return snapshot;
|
|
221
273
|
};
|
|
222
|
-
|
|
274
|
+
const snapshot = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
|
275
|
+
if (snapshot.status === "pending") throw snapshot.promise;
|
|
276
|
+
return snapshot.value;
|
|
223
277
|
}
|
|
224
278
|
/**
|
|
225
279
|
* Returns form props and a field register function for building cart forms.
|
package/dist/react/cart.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cart.mjs","names":[],"sources":["../../src/react/cart.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useSyncExternalStore,\n type FormHTMLAttributes,\n type ReactNode,\n type RefCallback,\n type SubmitEvent,\n} from \"react\";\n\nimport { trackCartAnalytics } from \"../core/analytics/cart-tracker\";\nimport { attachQuantityInput } from \"../core/cart/attach-quantity-input\";\nimport {\n configureCartEndpoint as configureCoreCartEndpoint,\n createCartStore,\n type CreateCartStoreOptions,\n type CartActions,\n type CartStore,\n} from \"../core/cart/cart\";\nimport { createCartFormRegister, type CartFormRegister } from \"../core/cart/form\";\nimport type { CartDataFromHandlers } from \"../core/cart/server-handlers\";\nimport { type CartData, type CartState } from \"../core/cart/state\";\n\nconst DEFAULT_CART_ENDPOINT = \"/api/cart\";\n\nlet cartEndpoint = DEFAULT_CART_ENDPOINT;\n\nconst CartContext = createContext<CartStore | null>(null);\n\nexport function configureCartEndpoint(endpoint: string): void {\n cartEndpoint = endpoint;\n configureCoreCartEndpoint(endpoint);\n}\n\nexport function getCartEndpoint(): string {\n return cartEndpoint;\n}\n\n/**\n * Props for the cart provider that `createCartComponents` returns.\n */\ntype TypedCartProviderProps<TData extends CartData> = {\n /**\n * The `data` property of the cart GET handler's result, or a promise of that data. Without initial data, the store loads the cart from `/api/cart` after the provider mounts, and the server-rendered page shows an empty cart. The provider creates its store from the first value and ignores later changes.\n */\n initialData?: CartInitialData<TData>;\n /** Content that can read the cart through the cart hooks. */\n children?: ReactNode;\n};\n\ntype CartInitialData<TData extends CartData = CartData> =\n CreateCartStoreOptions<TData>[\"initialData\"];\n\nexport type { CartActions };\n\n/** A hook that returns the slice of cart state that your selector picks. */\ntype CartStateHook<TData extends CartData> =\n /**\n * @param selector - Picks the value to return from the cart state.\n * @param isEqual - Compares the previous and next selected values. Return `true` to skip the re-render. Without the function, the hook compares the values by reference.\n * @returns The selected value.\n */\n <S>(selector: (state: CartState<TData>) => S, isEqual?: (a: S, b: S) => boolean) => S;\n\n/** A hook that returns the slice of cart state that your selector picks, or `undefined` outside the cart provider. */\ntype OptionalCartStateHook<TData extends CartData> =\n /**\n * @param selector - Picks the value to return from the cart state.\n * @param isEqual - Compares the previous and next selected values. Return `true` to skip the re-render. Without the function, the hook compares the values by reference.\n * @returns The selected value, or `undefined` outside the cart provider.\n */\n <S>(selector: (state: CartState<TData>) => S, isEqual?: (a: S, b: S) => boolean) => S | undefined;\n\n/** Callbacks that run around a cart form submission. */\ninterface CartFormPropsOptions {\n /** Runs before the cart store handles the submission. Call `event.preventDefault()` to skip the cart submission. */\n beforeSubmit?: (e: SubmitEvent<HTMLFormElement>) => void;\n /** Runs as soon as the cart change starts, before the server responds. */\n afterSubmit?: (e: SubmitEvent<HTMLFormElement>) => void;\n}\n\n/** The form props function and the register function for a cart form. */\ninterface CartFormBindings {\n /** Returns the `method`, `action`, and `onSubmit` props to spread on the form. The form posts to the cart endpoint, and the cart store handles each submission. */\n formProps: (opts?: CartFormPropsOptions) => FormHTMLAttributes<HTMLFormElement>;\n /** Returns the attributes for a cart field or action button. Register the quantity field with `interactive: true` to submit the form when the quantity changes. The interactive input takes the quantity as a default value and stays uncontrolled. */\n register: CartFormRegister;\n}\n\n/**\n * The cart provider and hooks that `createCartComponents` returns, typed with the custom cart fields from your cart server handlers.\n */\ntype TypedCartComponents<TData extends CartData> = {\n /** Creates the cart store and shares the store with the cart hooks inside the provider. The provider connects the store on mount and destroys the store on unmount. */\n CartProvider: (props: TypedCartProviderProps<TData>) => ReactNode;\n /**\n * Returns the slice of cart state that the selector picks. The component re-renders when the selected value changes by reference, or when your `isEqual` function reports a change. A selector that returns the full state re-renders on every cart update.\n */\n useCart: CartStateHook<TData>;\n /** Suspends while the full cart loads, then returns the slice of cart state that the selector picks. Wrap the component in a `Suspense` boundary. */\n useSuspenseCart: CartStateHook<TData>;\n /**\n * Returns the slice of cart state that the selector picks, or `undefined` outside the provider. Use the hook in a component that also renders above the provider, such as a cart badge on an error page.\n */\n useOptionalCart: OptionalCartStateHook<TData>;\n /** Returns the `refresh()` action. Call `refresh()` after a server-side cart change that skips cart forms. */\n useCartActions: typeof useCartActions;\n /** Returns form props and a register function for cart forms. */\n useCartForm: typeof useCartForm;\n};\n\n/**\n * Returns a cart provider and cart hooks typed to the cart fragment in your cart server handlers.\n * Pass `typeof cartServerHandlers` as the `THandlers` type argument. Every hook's cart state then includes your custom cart fields.\n *\n * On mount, the provider sends cart requests to `/api/cart` and connects the store. Every hook except `useOptionalCart` throws when you call it outside the provider.\n *\n * @returns The cart provider, the cart state hooks, and the cart actions and form hooks.\n *\n * @example\n * ```tsx\n * import type { cartServerHandlers } from \"./server/cart.server\";\n *\n * const {\n * CartProvider,\n * useCart,\n * useSuspenseCart,\n * useOptionalCart,\n * useCartActions,\n * useCartForm,\n * } = createCartComponents<typeof cartServerHandlers>();\n * ```\n * @publicDocs\n */\nexport function createCartComponents<THandlers>(): TypedCartComponents<\n CartDataFromHandlers<THandlers>\n> {\n type TData = CartDataFromHandlers<THandlers>;\n\n function TypedCartProvider({ initialData, children }: TypedCartProviderProps<TData>) {\n return (\n <CartProvider initialData={initialData as CartInitialData | undefined}>\n {children}\n </CartProvider>\n );\n }\n\n function useTypedCart<S>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n ): S {\n return useCart<CartData, S>((state) => selector(state as CartState<TData>), isEqual);\n }\n\n function useTypedOptionalCart<S>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n ): S | undefined {\n return useOptionalCart<CartData, S>((state) => selector(state as CartState<TData>), isEqual);\n }\n\n function useSuspenseCart<S>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n ): S {\n const readyPromise = useCart((state) => state.readyPromise);\n if (readyPromise) throw readyPromise;\n return useCart(selector, isEqual);\n }\n\n return {\n CartProvider: TypedCartProvider,\n useCart: useTypedCart,\n useSuspenseCart,\n useOptionalCart: useTypedOptionalCart,\n useCartActions,\n useCartForm,\n } as const;\n}\n\nexport function useCartStore(hookName = \"useCart\"): CartStore {\n const store = useContext(CartContext);\n if (!store) throw new Error(`${hookName} must be used inside <CartProvider>.`);\n return store;\n}\n\nfunction useOptionalCartStore(): CartStore | null {\n return useContext(CartContext);\n}\n\n/**\n * Creates a cart store and shares the store with the cart hooks inside the provider.\n *\n * Render every cart hook inside the provider. On mount, the provider sends cart requests to `/api/cart` and connects the store. On unmount, the provider destroys the store. The provider creates its store from the first initial data and ignores later changes.\n *\n * @example\n * ```tsx\n * // In your root layout\n * <CartProvider initialData={{ cart: loaderData.cart }}>\n * <App />\n * </CartProvider>\n * ```\n * @param props - The initial cart data and the content that uses the cart hooks.\n * @returns A context provider that shares the cart store with its children.\n * @publicDocs\n */\nexport function CartProvider({\n initialData,\n children,\n}: {\n initialData?: CartInitialData;\n children?: ReactNode;\n}) {\n // oxlint-disable-next-line react-hooks/exhaustive-deps -- store is created once with the initial server data\n const store = useMemo(() => createCartStore({ initialData }), []);\n\n useEffect(() => {\n configureCoreCartEndpoint(cartEndpoint);\n store.connect();\n return () => {\n store.destroy();\n };\n }, [store]);\n\n return <CartContext.Provider value={store}>{children}</CartContext.Provider>;\n}\n\n/**\n * Returns the slice of cart state that the selector picks.\n *\n * The component re-renders when the selected value changes by reference, or when your `isEqual` function reports a change. A selector that returns the full state re-renders on every cart update.\n *\n * @param selector The function that picks a value from the cart state.\n * @param isEqual The function that compares the previous and next selected values. Return `true` to skip the re-render.\n * @returns The selected value.\n *\n * @example\n * ```tsx\n * // Select cart lines\n * const lines = useCart((state) => state.data.lines.nodes);\n *\n * // Select pending state for a specific line\n * const isPending = useCart((state) => state.pending.lines.has(lineId));\n *\n * // Custom equality to avoid re-renders on unchanged totals\n * const total = useCart(\n * (state) => state.data.cost.totalAmount,\n * (a, b) => a.amount === b.amount,\n * );\n * ```\n * @publicDocs\n */\nexport function useCart<TData extends CartData = CartData, S = unknown>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n): S {\n const store = useCartStore();\n return useCartSelector(store, selector, isEqual) as S;\n}\n\n/**\n * Returns the cart action that reloads the cart after a cart change outside cart forms.\n *\n * Call `refresh()` after a server-side cart change that skips cart forms, such as a server action that creates the cart.\n *\n * @returns The `refresh()` action.\n *\n * @example\n * ```tsx\n * const { refresh } = useCartActions();\n *\n * async function handleServerAction() {\n * await createCartOnServer();\n * refresh(); // re-fetch the cart\n * }\n * ```\n * @publicDocs\n */\nexport function useCartActions(): CartActions {\n const store = useCartStore(\"useCartActions\");\n\n return useMemo(() => ({ refresh: store.refresh }), [store]);\n}\n\n/**\n * Publishes cart analytics events when the customer's cart changes.\n *\n * Call the hook once near the root of your app, inside `CartProvider`. The hook starts tracking on mount and stops on unmount. The hook publishes `cart_updated`, `product_added_to_cart`, and `product_removed_from_cart` events.\n *\n * The hook throws outside `CartProvider`, and when the Shopify analytics bus isn't available. Render `ShopifyScripts` before the hook runs.\n *\n * @returns Nothing. The hook tracks cart analytics for the lifetime of the calling component.\n *\n * @example\n * ```tsx\n * function App() {\n * useCartAnalytics();\n * return <Layout />;\n * }\n * ```\n * @publicDocs\n */\nexport function useCartAnalytics(): void {\n const store = useCartStore(\"useCartAnalytics\");\n\n useEffect(() => trackCartAnalytics(store), [store]);\n}\n\n/**\n * Returns a selected slice of cart state, or `undefined` when the component renders outside the cart provider.\n *\n * Use the hook in a component that also renders above the provider, such as a header cart badge on an error page.\n * Wherever the provider always exists, use `useCart`, which throws when the provider is missing.\n *\n * @param selector The function that picks a value from the cart state.\n * @param isEqual The function that compares the previous and next selected values. Return `true` to skip the re-render.\n * @returns The selected value, or `undefined` outside the provider.\n *\n * @example\n * ```tsx\n * function CartBadge() {\n * const count = useOptionalCart((state) => state.data.totalQuantity);\n * if (count === undefined) return null;\n * return <span>Cart ({count})</span>;\n * }\n * ```\n * @publicDocs\n */\nexport function useOptionalCart<TData extends CartData = CartData, S = unknown>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n): S | undefined {\n const store = useOptionalCartStore();\n if (!store) return undefined;\n return useCartSelector(store, selector, isEqual);\n}\n\nfunction useCartSelector<TData extends CartData = CartData, S = unknown>(\n store: CartStore,\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n): S {\n const cachedRef = useRef<{ state: unknown; selector: typeof selector; value: S } | null>(null);\n\n const getSnapshot = () => {\n const state = store.getState() as CartState<TData>;\n\n if (\n cachedRef.current &&\n cachedRef.current.state === state &&\n cachedRef.current.selector === selector\n ) {\n return cachedRef.current.value;\n }\n\n const next = selector(state);\n\n if (cachedRef.current && isEqual?.(cachedRef.current.value, next)) {\n cachedRef.current = { state, selector, value: cachedRef.current.value };\n return cachedRef.current.value;\n }\n\n cachedRef.current = { state, selector, value: next };\n return next;\n };\n\n return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\n/**\n * Returns form props and a field register function for building cart forms.\n *\n * Spread `formProps()` on the form. The form posts to the cart endpoint. On submit, the form props stop the browser's navigation and send the form through the cart store.\n * The submit handler ignores a failed cart change. Read failures from the cart state's `errors`.\n * To skip the cart submission, call `event.preventDefault()` in a `beforeSubmit` callback. The `afterSubmit` callback runs as soon as the cart change starts, before the server responds.\n * The register function returns the HTML attributes for each cart field and action.\n *\n * Register the quantity field with `interactive: true` to submit the form when the quantity changes. The interactive input takes the quantity as a default value and stays uncontrolled.\n *\n * @returns The form props function and the register function.\n *\n * @example\n * ```tsx\n * function LineItem({ line }) {\n * const { formProps, register } = useCartForm();\n *\n * return (\n * <form {...formProps()}>\n * <input {...register(\"lineId\", { value: line.id })} />\n * <input {...register(\"quantity\", { value: line.quantity, interactive: true })} />\n * <button {...register(\"set\")} />\n * <button {...register(\"increase\")}>+</button>\n * <button {...register(\"decrease\")}>−</button>\n * <button {...register(\"remove\")}>Remove</button>\n * </form>\n * );\n * }\n * ```\n * @publicDocs\n */\nexport function useCartForm(): CartFormBindings {\n const store = useCartStore(\"useCartForm\");\n const coreRegister = useMemo(() => createCartFormRegister(), []);\n\n const register = useMemo(() => {\n type Register = typeof coreRegister;\n const wrapped = ((...args: Parameters<Register>) => {\n const result = (coreRegister as Function)(...args);\n const [field, opts] = args as [string, { interactive?: boolean }?];\n\n if (field === \"quantity\" && opts?.interactive) {\n let cleanup: (() => void) | null = null;\n const ref: RefCallback<HTMLInputElement> = (el) => {\n cleanup?.();\n cleanup = null;\n if (el) {\n const form = el.closest(\"form\");\n if (form) cleanup = attachQuantityInput(el, form);\n }\n };\n const { value: _, ...rest } = result;\n return { ...rest, defaultValue: result.value, ref };\n }\n\n return result;\n }) as Register;\n return wrapped;\n }, [coreRegister]);\n\n const formProps = (opts?: CartFormPropsOptions): FormHTMLAttributes<HTMLFormElement> => ({\n onSubmit: (e: SubmitEvent<HTMLFormElement>) => {\n opts?.beforeSubmit?.(e);\n if (e.defaultPrevented) return;\n e.preventDefault();\n store.handleFormSubmit(e.nativeEvent).catch(() => {});\n opts?.afterSubmit?.(e);\n },\n method: \"post\",\n action: cartEndpoint,\n });\n\n return { formProps, register };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cart.mjs","names":[],"sources":["../../src/react/cart.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useSyncExternalStore,\n type FormHTMLAttributes,\n type ReactNode,\n type RefCallback,\n type SubmitEvent,\n} from \"react\";\n\nimport { trackCartAnalytics } from \"../core/analytics/cart-tracker\";\nimport { attachQuantityInput } from \"../core/cart/attach-quantity-input\";\nimport {\n configureCartEndpoint as configureCoreCartEndpoint,\n createCartStore,\n type CreateCartStoreOptions,\n type CartActions,\n type CartStore,\n} from \"../core/cart/cart\";\nimport { createCartFormRegister, type CartFormRegister } from \"../core/cart/form\";\nimport type { CartDataFromHandlers } from \"../core/cart/server-handlers\";\nimport { type CartData, type CartState } from \"../core/cart/state\";\n\nconst DEFAULT_CART_ENDPOINT = \"/api/cart\";\n\nlet cartEndpoint = DEFAULT_CART_ENDPOINT;\n\ninterface CartContextValue {\n store: CartStore;\n hydrationSnapshot: CartState;\n}\n\nconst CartContext = createContext<CartContextValue | null>(null);\n\nexport function configureCartEndpoint(endpoint: string): void {\n cartEndpoint = endpoint;\n configureCoreCartEndpoint(endpoint);\n}\n\nexport function getCartEndpoint(): string {\n return cartEndpoint;\n}\n\n/**\n * Props for the cart provider that `createCartComponents` returns.\n */\ntype TypedCartProviderProps<TData extends CartData> = {\n /**\n * The `data` property of the cart GET handler's result, or a promise of that data. Without initial data, the store loads the cart from `/api/cart` after the provider mounts, and the server-rendered page shows an empty cart. The provider creates its store from the first value and ignores later changes.\n */\n initialData?: CartInitialData<TData>;\n /** Content that can read the cart through the cart hooks. */\n children?: ReactNode;\n};\n\ntype CartInitialData<TData extends CartData = CartData> =\n CreateCartStoreOptions<TData>[\"initialData\"];\n\nexport type { CartActions };\n\n/** A hook that returns the slice of cart state that your selector picks. */\ntype CartStateHook<TData extends CartData> =\n /**\n * @param selector - Picks the value to return from the cart state.\n * @param isEqual - Compares the previous and next selected values. Return `true` to skip the re-render. Without the function, the hook compares the values by reference.\n * @returns The selected value.\n */\n <S>(selector: (state: CartState<TData>) => S, isEqual?: (a: S, b: S) => boolean) => S;\n\n/** A hook that returns the slice of cart state that your selector picks, or `undefined` outside the cart provider. */\ntype OptionalCartStateHook<TData extends CartData> =\n /**\n * @param selector - Picks the value to return from the cart state.\n * @param isEqual - Compares the previous and next selected values. Return `true` to skip the re-render. Without the function, the hook compares the values by reference.\n * @returns The selected value, or `undefined` outside the cart provider.\n */\n <S>(selector: (state: CartState<TData>) => S, isEqual?: (a: S, b: S) => boolean) => S | undefined;\n\n/** Callbacks that run around a cart form submission. */\ninterface CartFormPropsOptions {\n /** Runs before the cart store handles the submission. Call `event.preventDefault()` to skip the cart submission. */\n beforeSubmit?: (e: SubmitEvent<HTMLFormElement>) => void;\n /** Runs as soon as the cart change starts, before the server responds. */\n afterSubmit?: (e: SubmitEvent<HTMLFormElement>) => void;\n}\n\n/** The form props function and the register function for a cart form. */\ninterface CartFormBindings {\n /** Returns the `method`, `action`, and `onSubmit` props to spread on the form. The form posts to the cart endpoint, and the cart store handles each submission. */\n formProps: (opts?: CartFormPropsOptions) => FormHTMLAttributes<HTMLFormElement>;\n /** Returns the attributes for a cart field or action button. Register the quantity field with `interactive: true` to submit the form when the quantity changes. The interactive input takes the quantity as a default value and stays uncontrolled. */\n register: CartFormRegister;\n}\n\n/**\n * The cart provider and hooks that `createCartComponents` returns, typed with the custom cart fields from your cart server handlers.\n */\ntype TypedCartComponents<TData extends CartData> = {\n /** Creates the cart store and shares the store with the cart hooks inside the provider. The provider connects the store on mount and destroys the store on unmount. */\n CartProvider: (props: TypedCartProviderProps<TData>) => ReactNode;\n /**\n * Returns the slice of cart state that the selector picks. The component re-renders when the selected value changes by reference, or when your `isEqual` function reports a change. A selector that returns the full state re-renders on every cart update.\n */\n useCart: CartStateHook<TData>;\n /** Suspends while the full cart loads, then returns the slice of cart state that the selector picks. Wrap the component in a `Suspense` boundary. */\n useSuspenseCart: CartStateHook<TData>;\n /**\n * Returns the slice of cart state that the selector picks, or `undefined` outside the provider. Use the hook in a component that also renders above the provider, such as a cart badge on an error page.\n */\n useOptionalCart: OptionalCartStateHook<TData>;\n /** Returns the `refresh()` action. Call `refresh()` after a server-side cart change that skips cart forms. */\n useCartActions: typeof useCartActions;\n /** Returns form props and a register function for cart forms. */\n useCartForm: typeof useCartForm;\n};\n\n/**\n * Returns a cart provider and cart hooks typed to the cart fragment in your cart server handlers.\n * Pass `typeof cartServerHandlers` as the `THandlers` type argument. Every hook's cart state then includes your custom cart fields.\n *\n * On mount, the provider sends cart requests to `/api/cart` and connects the store. Every hook except `useOptionalCart` throws when you call it outside the provider.\n *\n * @returns The cart provider, the cart state hooks, and the cart actions and form hooks.\n *\n * @example\n * ```tsx\n * import type { cartServerHandlers } from \"./server/cart.server\";\n *\n * const {\n * CartProvider,\n * useCart,\n * useSuspenseCart,\n * useOptionalCart,\n * useCartActions,\n * useCartForm,\n * } = createCartComponents<typeof cartServerHandlers>();\n * ```\n * @publicDocs\n */\nexport function createCartComponents<THandlers>(): TypedCartComponents<\n CartDataFromHandlers<THandlers>\n> {\n type TData = CartDataFromHandlers<THandlers>;\n\n function TypedCartProvider({ initialData, children }: TypedCartProviderProps<TData>) {\n return (\n <CartProvider initialData={initialData as CartInitialData | undefined}>\n {children}\n </CartProvider>\n );\n }\n\n function useTypedCart<S>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n ): S {\n return useCart<CartData, S>((state) => selector(state as CartState<TData>), isEqual);\n }\n\n function useTypedOptionalCart<S>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n ): S | undefined {\n return useOptionalCart<CartData, S>((state) => selector(state as CartState<TData>), isEqual);\n }\n\n function useSuspenseCart<S>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n ): S {\n return useSuspenseCartSelector(selector, isEqual);\n }\n\n return {\n CartProvider: TypedCartProvider,\n useCart: useTypedCart,\n useSuspenseCart,\n useOptionalCart: useTypedOptionalCart,\n useCartActions,\n useCartForm,\n } as const;\n}\n\nexport function useCartStore(hookName = \"useCart\"): CartStore {\n return useCartContext(hookName).store;\n}\n\nfunction useCartContext(hookName = \"useCart\"): CartContextValue {\n const context = useContext(CartContext);\n if (!context) throw new Error(`${hookName} must be used inside <CartProvider>.`);\n return context;\n}\n\nfunction useOptionalCartContext(): CartContextValue | null {\n return useContext(CartContext);\n}\n\n/**\n * Creates a cart store and shares the store with the cart hooks inside the provider.\n *\n * Render every cart hook inside the provider. On mount, the provider sends cart requests to `/api/cart` and connects the store. On unmount, the provider destroys the store. The provider creates its store from the first initial data and ignores later changes.\n *\n * @example\n * ```tsx\n * // In your root layout\n * <CartProvider initialData={{ cart: loaderData.cart }}>\n * <App />\n * </CartProvider>\n * ```\n * @param props - The initial cart data and the content that uses the cart hooks.\n * @returns A context provider that shares the cart store with its children.\n * @publicDocs\n */\nexport function CartProvider({\n initialData,\n children,\n}: {\n initialData?: CartInitialData;\n children?: ReactNode;\n}) {\n const context = useMemo(() => {\n const store = createCartStore({ initialData });\n // Plain consumers must hydrate from the state used for SSR, even if the live store settles first.\n return { store, hydrationSnapshot: store.getState() };\n // oxlint-disable-next-line react-hooks/exhaustive-deps -- provider initialData seeds one store for its lifetime\n }, []);\n const { store } = context;\n\n useEffect(() => {\n configureCoreCartEndpoint(cartEndpoint);\n store.connect();\n return () => {\n store.destroy();\n };\n }, [store]);\n\n return <CartContext.Provider value={context}>{children}</CartContext.Provider>;\n}\n\n/**\n * Returns the slice of cart state that the selector picks.\n *\n * The component re-renders when the selected value changes by reference, or when your `isEqual` function reports a change. A selector that returns the full state re-renders on every cart update.\n *\n * @param selector The function that picks a value from the cart state.\n * @param isEqual The function that compares the previous and next selected values. Return `true` to skip the re-render.\n * @returns The selected value.\n *\n * @example\n * ```tsx\n * // Select cart lines\n * const lines = useCart((state) => state.data.lines.nodes);\n *\n * // Select pending state for a specific line\n * const isPending = useCart((state) => state.pending.lines.has(lineId));\n *\n * // Custom equality to avoid re-renders on unchanged totals\n * const total = useCart(\n * (state) => state.data.cost.totalAmount,\n * (a, b) => a.amount === b.amount,\n * );\n * ```\n * @publicDocs\n */\nexport function useCart<TData extends CartData = CartData, S = unknown>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n): S {\n const { store, hydrationSnapshot } = useCartContext();\n return useCartSelector(store, hydrationSnapshot, selector, isEqual) as S;\n}\n\n/**\n * Returns the cart action that reloads the cart after a cart change outside cart forms.\n *\n * Call `refresh()` after a server-side cart change that skips cart forms, such as a server action that creates the cart.\n *\n * @returns The `refresh()` action.\n *\n * @example\n * ```tsx\n * const { refresh } = useCartActions();\n *\n * async function handleServerAction() {\n * await createCartOnServer();\n * refresh(); // re-fetch the cart\n * }\n * ```\n * @publicDocs\n */\nexport function useCartActions(): CartActions {\n const store = useCartStore(\"useCartActions\");\n\n return useMemo(() => ({ refresh: store.refresh }), [store]);\n}\n\n/**\n * Publishes cart analytics events when the customer's cart changes.\n *\n * Call the hook once near the root of your app, inside `CartProvider`. The hook starts tracking on mount and stops on unmount. The hook publishes `cart_updated`, `product_added_to_cart`, and `product_removed_from_cart` events.\n *\n * The hook throws outside `CartProvider`, and when the Shopify analytics bus isn't available. Render `ShopifyScripts` before the hook runs.\n *\n * @returns Nothing. The hook tracks cart analytics for the lifetime of the calling component.\n *\n * @example\n * ```tsx\n * function App() {\n * useCartAnalytics();\n * return <Layout />;\n * }\n * ```\n * @publicDocs\n */\nexport function useCartAnalytics(): void {\n const store = useCartStore(\"useCartAnalytics\");\n\n useEffect(() => trackCartAnalytics(store), [store]);\n}\n\n/**\n * Returns a selected slice of cart state, or `undefined` when the component renders outside the cart provider.\n *\n * Use the hook in a component that also renders above the provider, such as a header cart badge on an error page.\n * Wherever the provider always exists, use `useCart`, which throws when the provider is missing.\n *\n * @param selector The function that picks a value from the cart state.\n * @param isEqual The function that compares the previous and next selected values. Return `true` to skip the re-render.\n * @returns The selected value, or `undefined` outside the provider.\n *\n * @example\n * ```tsx\n * function CartBadge() {\n * const count = useOptionalCart((state) => state.data.totalQuantity);\n * if (count === undefined) return null;\n * return <span>Cart ({count})</span>;\n * }\n * ```\n * @publicDocs\n */\nexport function useOptionalCart<TData extends CartData = CartData, S = unknown>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n): S | undefined {\n const context = useOptionalCartContext();\n if (!context) return undefined;\n return useCartSelector(context.store, context.hydrationSnapshot, selector, isEqual);\n}\n\nfunction useCartSelector<TData extends CartData = CartData, S = unknown>(\n store: CartStore,\n hydrationSnapshot: CartState,\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n): S {\n const liveCacheRef = useRef<SelectorCache<S> | null>(null);\n const hydrationCacheRef = useRef<SelectorCache<S> | null>(null);\n\n const getSnapshot = () =>\n selectCartSnapshot(store.getState() as CartState<TData>, selector, isEqual, liveCacheRef);\n const getServerSnapshot = () =>\n selectCartSnapshot(hydrationSnapshot as CartState<TData>, selector, isEqual, hydrationCacheRef);\n\n return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);\n}\n\ninterface SelectorCache<S> {\n state: unknown;\n selector: unknown;\n value: S;\n}\n\nfunction selectCartSnapshot<TData extends CartData, S>(\n state: CartState<TData>,\n selector: (state: CartState<TData>) => S,\n isEqual: ((a: S, b: S) => boolean) | undefined,\n cacheRef: { current: SelectorCache<S> | null },\n): S {\n if (cacheRef.current?.state === state && cacheRef.current.selector === selector) {\n return cacheRef.current.value;\n }\n\n const next = selector(state);\n\n if (cacheRef.current && isEqual?.(cacheRef.current.value, next)) {\n cacheRef.current = { state, selector, value: cacheRef.current.value };\n return cacheRef.current.value;\n }\n\n cacheRef.current = { state, selector, value: next };\n return next;\n}\n\ntype SuspenseSnapshot<S> =\n | { status: \"pending\"; promise: PromiseLike<void> }\n | { status: \"ready\"; value: S };\n\ninterface SuspenseSelectorCache<S> {\n state: unknown;\n selector: unknown;\n snapshot: SuspenseSnapshot<S>;\n}\n\nfunction useSuspenseCartSelector<TData extends CartData, S>(\n selector: (state: CartState<TData>) => S,\n isEqual?: (a: S, b: S) => boolean,\n): S {\n const { store } = useCartContext();\n const cacheRef = useRef<SuspenseSelectorCache<S> | null>(null);\n\n // Suspense retries must read live readiness and data; the hydration snapshot would freeze the fallback.\n const getSnapshot = () => {\n const state = store.getState() as CartState<TData>;\n\n if (cacheRef.current?.state === state && cacheRef.current.selector === selector) {\n return cacheRef.current.snapshot;\n }\n\n if (state.readyPromise) {\n const snapshot: SuspenseSnapshot<S> = { status: \"pending\", promise: state.readyPromise };\n cacheRef.current = { state, selector, snapshot };\n return snapshot;\n }\n\n const value = selector(state);\n const previous = cacheRef.current?.snapshot;\n\n if (previous?.status === \"ready\" && isEqual?.(previous.value, value)) {\n cacheRef.current = { state, selector, snapshot: previous };\n return previous;\n }\n\n const snapshot: SuspenseSnapshot<S> = { status: \"ready\", value };\n cacheRef.current = { state, selector, snapshot };\n return snapshot;\n };\n\n const snapshot = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n if (snapshot.status === \"pending\") throw snapshot.promise;\n return snapshot.value;\n}\n\n/**\n * Returns form props and a field register function for building cart forms.\n *\n * Spread `formProps()` on the form. The form posts to the cart endpoint. On submit, the form props stop the browser's navigation and send the form through the cart store.\n * The submit handler ignores a failed cart change. Read failures from the cart state's `errors`.\n * To skip the cart submission, call `event.preventDefault()` in a `beforeSubmit` callback. The `afterSubmit` callback runs as soon as the cart change starts, before the server responds.\n * The register function returns the HTML attributes for each cart field and action.\n *\n * Register the quantity field with `interactive: true` to submit the form when the quantity changes. The interactive input takes the quantity as a default value and stays uncontrolled.\n *\n * @returns The form props function and the register function.\n *\n * @example\n * ```tsx\n * function LineItem({ line }) {\n * const { formProps, register } = useCartForm();\n *\n * return (\n * <form {...formProps()}>\n * <input {...register(\"lineId\", { value: line.id })} />\n * <input {...register(\"quantity\", { value: line.quantity, interactive: true })} />\n * <button {...register(\"set\")} />\n * <button {...register(\"increase\")}>+</button>\n * <button {...register(\"decrease\")}>−</button>\n * <button {...register(\"remove\")}>Remove</button>\n * </form>\n * );\n * }\n * ```\n * @publicDocs\n */\nexport function useCartForm(): CartFormBindings {\n const store = useCartStore(\"useCartForm\");\n const coreRegister = useMemo(() => createCartFormRegister(), []);\n\n const register = useMemo(() => {\n type Register = typeof coreRegister;\n const wrapped = ((...args: Parameters<Register>) => {\n const result = (coreRegister as Function)(...args);\n const [field, opts] = args as [string, { interactive?: boolean }?];\n\n if (field === \"quantity\" && opts?.interactive) {\n let cleanup: (() => void) | null = null;\n const ref: RefCallback<HTMLInputElement> = (el) => {\n cleanup?.();\n cleanup = null;\n if (el) {\n const form = el.closest(\"form\");\n if (form) cleanup = attachQuantityInput(el, form);\n }\n };\n const { value: _, ...rest } = result;\n return { ...rest, defaultValue: result.value, ref };\n }\n\n return result;\n }) as Register;\n return wrapped;\n }, [coreRegister]);\n\n const formProps = (opts?: CartFormPropsOptions): FormHTMLAttributes<HTMLFormElement> => ({\n onSubmit: (e: SubmitEvent<HTMLFormElement>) => {\n opts?.beforeSubmit?.(e);\n if (e.defaultPrevented) return;\n e.preventDefault();\n store.handleFormSubmit(e.nativeEvent).catch(() => {});\n opts?.afterSubmit?.(e);\n },\n method: \"post\",\n action: cartEndpoint,\n });\n\n return { formProps, register };\n}\n"],"mappings":"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"}
|
package/dist/vite/index.d.mts
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
import { LOCAL_HTTPS_DEFAULTS, LocalHttpsDevServerConfig, LocalHttpsOptions, LocalHttpsPlugin, ProvisionLocalHttpsOptions, localHttps, provisionLocalHttps } from "./local-https.mjs";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
//#region src/vite/index.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Projects built on 2026.4 import this name from their `vite.config`. Without it, a project whose `^2026.4.0` range resolved to 2026.10 fails at ESM link time with "does not provide an export named 'hydrogen'" before any Hydrogen code can explain the collision. This export turns that failure into a message with the fix.
|
|
6
|
+
*
|
|
7
|
+
* @deprecated Removed in `@shopify/hydrogen` 2026.10. Pin `@shopify/hydrogen` to `~2026.4.0` to keep using the `hydrogen()` Vite plugin.
|
|
8
|
+
*/
|
|
9
|
+
declare function hydrogen(): never;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { LOCAL_HTTPS_DEFAULTS, LocalHttpsDevServerConfig, LocalHttpsOptions, LocalHttpsPlugin, ProvisionLocalHttpsOptions, hydrogen, localHttps, provisionLocalHttps };
|
|
12
|
+
//# sourceMappingURL=index.d.mts.map
|
package/dist/vite/index.mjs
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
1
|
import { LOCAL_HTTPS_DEFAULTS, localHttps, provisionLocalHttps } from "./local-https.mjs";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
//#region src/vite/index.ts
|
|
4
|
+
const HYDROGEN_MIGRATION_GUIDE_URL = "https://shopify.dev/docs/storefronts/hydrogen/migrate";
|
|
5
|
+
/**
|
|
6
|
+
* Projects built on 2026.4 import this name from their `vite.config`. Without it, a project whose `^2026.4.0` range resolved to 2026.10 fails at ESM link time with "does not provide an export named 'hydrogen'" before any Hydrogen code can explain the collision. This export turns that failure into a message with the fix.
|
|
7
|
+
*
|
|
8
|
+
* @deprecated Removed in `@shopify/hydrogen` 2026.10. Pin `@shopify/hydrogen` to `~2026.4.0` to keep using the `hydrogen()` Vite plugin.
|
|
9
|
+
*/
|
|
10
|
+
function hydrogen() {
|
|
11
|
+
throw new Error([
|
|
12
|
+
`This project is not compatible with @shopify/hydrogen 2026.10.2.`,
|
|
13
|
+
"Its vite.config imports `hydrogen()` from `@shopify/hydrogen/vite`, which 2026.10 and later no longer provide. A caret range such as `^2026.4.0` resolves to 2026.10.",
|
|
14
|
+
"",
|
|
15
|
+
"To stay on 2026.4, pin \"@shopify/hydrogen\" to \"~2026.4.0\" in package.json and reinstall.",
|
|
16
|
+
"",
|
|
17
|
+
`To move to 2026.10, follow the migration guide: ${HYDROGEN_MIGRATION_GUIDE_URL}`
|
|
18
|
+
].join("\n"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { LOCAL_HTTPS_DEFAULTS, hydrogen, localHttps, provisionLocalHttps };
|
|
23
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/vite/index.ts"],"sourcesContent":["export * from \"./local-https\";\n\nconst HYDROGEN_MIGRATION_GUIDE_URL = \"https://shopify.dev/docs/storefronts/hydrogen/migrate\";\n\n/**\n * Projects built on 2026.4 import this name from their `vite.config`. Without it, a project whose `^2026.4.0` range resolved to 2026.10 fails at ESM link time with \"does not provide an export named 'hydrogen'\" before any Hydrogen code can explain the collision. This export turns that failure into a message with the fix.\n *\n * @deprecated Removed in `@shopify/hydrogen` 2026.10. Pin `@shopify/hydrogen` to `~2026.4.0` to keep using the `hydrogen()` Vite plugin.\n */\nexport function hydrogen(): never {\n throw new Error(\n [\n `This project is not compatible with @shopify/hydrogen ${__HYDROGEN_VERSION__}.`,\n \"Its vite.config imports `hydrogen()` from `@shopify/hydrogen/vite`, which 2026.10 and later no longer provide. A caret range such as `^2026.4.0` resolves to 2026.10.\",\n \"\",\n 'To stay on 2026.4, pin \"@shopify/hydrogen\" to \"~2026.4.0\" in package.json and reinstall.',\n \"\",\n `To move to 2026.10, follow the migration guide: ${HYDROGEN_MIGRATION_GUIDE_URL}`,\n ].join(\"\\n\"),\n );\n}\n"],"mappings":";;;AAEA,MAAM,+BAA+B;;;;;;AAOrC,SAAgB,WAAkB;AAChC,OAAM,IAAI,MACR;EACE;EACA;EACA;EACA;EACA;EACA,mDAAmD;EACpD,CAAC,KAAK,KAAK,CACb"}
|
|
@@ -146,7 +146,6 @@ declare const ShopifyScripts: _$vue.DefineComponent<_$vue.ExtractPropTypes<{
|
|
|
146
146
|
required: true;
|
|
147
147
|
};
|
|
148
148
|
}>> & Readonly<{}>, {
|
|
149
|
-
nonce: string;
|
|
150
149
|
account: boolean | undefined;
|
|
151
150
|
analytics: ShopifyScriptsAnalyticsConfig | undefined;
|
|
152
151
|
consent: ConsentConfig | undefined;
|
|
@@ -155,6 +154,7 @@ declare const ShopifyScripts: _$vue.DefineComponent<_$vue.ExtractPropTypes<{
|
|
|
155
154
|
} | undefined;
|
|
156
155
|
i18n: ShopifyScriptsI18n;
|
|
157
156
|
inbox: boolean | undefined;
|
|
157
|
+
nonce: string;
|
|
158
158
|
shopifyAnalytics: boolean;
|
|
159
159
|
navigate: any;
|
|
160
160
|
routes: any;
|
package/package.json
CHANGED
|
@@ -44,11 +44,11 @@ On mutation:
|
|
|
44
44
|
3. When the promise resolves, the store folds that transaction into settled state and reapplies remaining projections. Overlapping mutations discard ambiguous response snapshots, then coalesce one authoritative cart refresh after the burst settles.
|
|
45
45
|
4. On failure, the store removes only the failed projection, preserves unrelated work, and clears the matching pending entry.
|
|
46
46
|
|
|
47
|
-
When overlapping mutations make response snapshots ambiguous, the store sets `state.revalidating` to `true`. It starts one authoritative refresh after those mutations settle and clears the flag when the refresh completes or fails. Until then, server-derived values such as costs remain at their last trustworthy value. A refresh failure preserves the locally reconciled cart and appears in `errors.network`.
|
|
47
|
+
When overlapping mutations make response snapshots ambiguous, the store sets `state.revalidating` to `true`. It starts one authoritative refresh after those mutations settle and clears the flag when the refresh completes or fails. Until then, server-derived values such as costs remain at their last trustworthy value. A refresh failure preserves the locally reconciled cart and appears in `errors.network`. A change made while a cart load is in flight is handled the same way: `loading` ends and the refresh brings in the rest of the cart.
|
|
48
48
|
|
|
49
49
|
App-owned cart mutations outside Standard Actions do not emit the events the store normally observes. After such a mutation succeeds, call `CartStore.refresh()` directly or use the framework binding's `useCartActions().refresh()`. The refresh waits for active optimistic work, reconciles custom fragment fields from the configured cart endpoint, and loads the cart when none exists yet (e.g. one just created server-side). Do not call it after ordinary Hydrogen cart forms; their Standard Actions events already synchronize the store. For cart metafields specifically, see the `hydrogen-cart-metafields` skill.
|
|
50
50
|
|
|
51
|
-
The store supersedes keyed mutations for the same line, discount batch, note, or complete attribute list. Relative additions remain independent so every submitted quantity reaches the server; their projections are reconciled together without disabling controls.
|
|
51
|
+
The store supersedes keyed mutations for the same line, discount batch, note, or complete attribute list. Relative additions remain independent so every submitted quantity reaches the server; their projections are reconciled together without disabling controls. A `Shopify.actions.updateCart` call that updates or removes several lines is keyed per line: each line is projected, pending and rolled back on its own, and a newer change to one of those lines supersedes only that line.
|
|
52
52
|
|
|
53
53
|
The cart store combines its lifecycle, timeout, and caller signals with `AbortSignal.any`, which Safari added in 17.4. The store already requires Safari 16 for `toSorted`, so Safari 16.0 through 17.3 is the gap. To keep add to cart working there, load an `AbortSignal.any` polyfill in the browser bundle before the store is created.
|
|
54
54
|
|
|
@@ -44,7 +44,6 @@ Serve the storefront over HTTPS and wire `handleShopifyRoutes` before framework
|
|
|
44
44
|
const shopifyRoute = handleShopifyRoutes({
|
|
45
45
|
request,
|
|
46
46
|
requestContext,
|
|
47
|
-
sessionManager,
|
|
48
47
|
storefrontClient,
|
|
49
48
|
routeTemplates,
|
|
50
49
|
handlers: [cartHandlers],
|
|
@@ -73,7 +72,8 @@ const redirect = await handleShopifyRedirects({
|
|
|
73
72
|
- Route handlers, cart server handlers, and `handleShopifyRedirects` accept any provided Storefront client.
|
|
74
73
|
- When creating a private client, resolve trusted `buyerIp` first and pass it to `createShopifyRequestContext`. Use the `hydrogen-storefront-client` buyer-IP guidance for the app's deployment.
|
|
75
74
|
- Create `requestContext` with `createShopifyRequestContext({ request, i18n, buyerIp })` where buyer context exists and the framework exposes a real `Request`; use `request: { headers }` only when no `Request` exists.
|
|
76
|
-
- Pass the same request-scoped `requestContext
|
|
75
|
+
- Pass the same request-scoped `requestContext` and `storefrontClient` into `handleShopifyRoutes` and registered handler groups, plus the same request-scoped `sessionManager` when a handler needs one.
|
|
76
|
+
- `handleShopifyRoutes` requires `sessionManager` only when a registered handler reads the session: Customer Account handlers, cart handlers created with `customerSession`, or custom handlers whose context includes it. Apps whose handlers never read the session, such as cart and predictive search only, can omit it. The requirement comes from the handler types, so a handler list that conditionally includes Customer Account handlers still needs a session manager on every request; when the account handlers are not registered, an in-memory session manager is enough, as in the starter templates. Annotate custom `createShopifyRouteHandler` contexts as `Omit<ShopifyRouteHandlerContext, "sessionManager">` when they do not use the session; a context that declares `sessionManager`, even as optional, requires one.
|
|
77
77
|
- Call `handleShopifyRoutes` without awaiting it immediately. It returns `null` synchronously when no route matches; only return or await the promise after checking that it is truthy.
|
|
78
78
|
- If the app has a request-level `try/catch` that converts errors into a `Response`, use `return await shopifyRoute` inside that boundary so rejected route promises reach the same error handling as synchronous setup failures. If the framework owns request error handling, return `shopifyRoute` directly. Do not add an inline `.catch()` unless the matched route intentionally needs different error handling.
|
|
79
79
|
- Pass `request` and `storefrontClient` into `handleShopifyRedirects`; it does not receive a session manager.
|
|
@@ -32,7 +32,7 @@ Prefer this over adding `.catch()` only to the returned promise: the request-lev
|
|
|
32
32
|
|
|
33
33
|
The scaffold defaults to a public client; `PUBLIC_STOREFRONT_API_TOKEN` may be unset, which means tokenless access (all mock.shop supports). Once the app has a private token and trusted buyer context, switch to `type: "private"` and resolve `buyerIp` per the `hydrogen-storefront-client` buyer-IP guidance.
|
|
34
34
|
|
|
35
|
-
This shape assumes app-owned `getBuyerIp
|
|
35
|
+
This shape assumes app-owned `getBuyerIp` and `routeTemplates` values. Cart and predictive search handlers do not read the session, so no `sessionManager` is passed; add a request-scoped one when registering Customer Account handlers or cart handlers created with `customerSession`.
|
|
36
36
|
|
|
37
37
|
```ts
|
|
38
38
|
import {
|
|
@@ -52,7 +52,6 @@ export async function handleRequest(request: Request, next: () => Promise<Respon
|
|
|
52
52
|
request,
|
|
53
53
|
i18n: { country: "US", language: "EN" },
|
|
54
54
|
});
|
|
55
|
-
const sessionManager = await createSessionManager(request);
|
|
56
55
|
const storefrontClient = createStorefrontClient({
|
|
57
56
|
type: "public",
|
|
58
57
|
requestContext,
|
|
@@ -65,7 +64,6 @@ export async function handleRequest(request: Request, next: () => Promise<Respon
|
|
|
65
64
|
const shopifyRoute = handleShopifyRoutes({
|
|
66
65
|
request,
|
|
67
66
|
requestContext,
|
|
68
|
-
sessionManager,
|
|
69
67
|
storefrontClient,
|
|
70
68
|
routeTemplates,
|
|
71
69
|
handlers: [cartHandlers, predictiveSearchHandlers],
|
|
@@ -111,7 +109,6 @@ export const middleware: Route.MiddlewareFunction[] = [
|
|
|
111
109
|
request,
|
|
112
110
|
i18n: { country: "US", language: "EN" },
|
|
113
111
|
});
|
|
114
|
-
const sessionManager = await createSessionManager(request);
|
|
115
112
|
const storefrontClient = createStorefrontClient({
|
|
116
113
|
type: "public",
|
|
117
114
|
requestContext,
|
|
@@ -124,7 +121,6 @@ export const middleware: Route.MiddlewareFunction[] = [
|
|
|
124
121
|
const shopifyRoute = handleShopifyRoutes({
|
|
125
122
|
request,
|
|
126
123
|
requestContext,
|
|
127
|
-
sessionManager,
|
|
128
124
|
storefrontClient,
|
|
129
125
|
routeTemplates,
|
|
130
126
|
handlers: [cartHandlers, predictiveSearchHandlers],
|
|
@@ -148,13 +144,12 @@ export const middleware: Route.MiddlewareFunction[] = [
|
|
|
148
144
|
|
|
149
145
|
SolidStart middleware can short-circuit before routing, but cannot reliably observe the final 404 after SSR streaming starts. Put `handleShopifyRoutes` in middleware and `handleShopifyRedirects` in a last-priority catch-all route.
|
|
150
146
|
|
|
151
|
-
In middleware,
|
|
147
|
+
In middleware, call `handleShopifyRoutes`. Create an app-owned request-scoped `sessionManager` first only if a registered handler reads the session:
|
|
152
148
|
|
|
153
149
|
```ts
|
|
154
150
|
const shopifyRoute = handleShopifyRoutes({
|
|
155
151
|
request: event.request,
|
|
156
152
|
requestContext,
|
|
157
|
-
sessionManager,
|
|
158
153
|
storefrontClient,
|
|
159
154
|
routeTemplates,
|
|
160
155
|
handlers: [cartHandlers, predictiveSearchHandlers],
|
|
@@ -21,7 +21,7 @@ Create `server/middleware/shopify.ts`:
|
|
|
21
21
|
|
|
22
22
|
The scaffold defaults to a public client; `PUBLIC_STOREFRONT_API_TOKEN` may be unset, which means tokenless access (all mock.shop supports). Once the app has a private token and trusted buyer context, switch to `type: "private"` and resolve `buyerIp` from the app's trusted deployment headers per the buyer-IP guidance from `hydrogen-storefront-client`.
|
|
23
23
|
|
|
24
|
-
Create app-owned `
|
|
24
|
+
Create an app-owned `routeTemplates` value before `handleShopifyRoutes`. Cart handlers do not read the session, so this example passes no `sessionManager`; add a request-scoped one when registering Customer Account handlers or cart handlers created with `customerSession`.
|
|
25
25
|
|
|
26
26
|
```ts
|
|
27
27
|
import {
|
|
@@ -40,13 +40,11 @@ export default defineEventHandler(async (event) => {
|
|
|
40
40
|
request,
|
|
41
41
|
i18n: { country: "US", language: "EN" },
|
|
42
42
|
});
|
|
43
|
-
const sessionManager = await createSessionManager(request);
|
|
44
43
|
const storefrontClient = createPublicStorefrontClient(requestContext);
|
|
45
44
|
|
|
46
45
|
const shopifyRoute = handleShopifyRoutes({
|
|
47
46
|
request,
|
|
48
47
|
requestContext,
|
|
49
|
-
sessionManager,
|
|
50
48
|
storefrontClient,
|
|
51
49
|
routeTemplates,
|
|
52
50
|
handlers: [cartHandlers],
|
|
@@ -232,7 +232,7 @@ export type ProductCardData =
|
|
|
232
232
|
|
|
233
233
|
### Variables and i18n auto-injection
|
|
234
234
|
|
|
235
|
-
`$country` and `$language`
|
|
235
|
+
`$country` and `$language` are auto-injected from `requestContext.i18n` when the query declares them as `$country: CountryCode` and `$language: LanguageCode` (nullable or `!`). Static clients still create a request context, usually with empty headers plus the resolved locale. You only pass the variables you own:
|
|
236
236
|
|
|
237
237
|
```ts
|
|
238
238
|
const PRODUCTS = gql(`
|
|
@@ -247,7 +247,9 @@ const { data } = await client.graphql(PRODUCTS, {
|
|
|
247
247
|
});
|
|
248
248
|
```
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
For those declarations the request context wins: the variables type rejects `country` and `language`, and the client overwrites any value passed anyway. To query another market, create the client with a request context for that `i18n`.
|
|
251
|
+
|
|
252
|
+
A same-named variable of any other type stays yours and keeps its nullability, for example `$country: String!` as a metafield key, `$country: [CountryCode!]!`, or `$language: ProductSortKeys`. Only the first operation's declarations count.
|
|
251
253
|
|
|
252
254
|
### Request state storage
|
|
253
255
|
|