@shopify/cli-hydrogen 11.1.10 → 11.1.12

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.
@@ -1,5 +1,35 @@
1
1
  # skeleton
2
2
 
3
+ ## 2026.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Improve screen reader experience for paginated product grids by hiding decorative arrow characters from assistive technology. ([#3557](https://github.com/Shopify/hydrogen/pull/3557)) by [@itsjustriley](https://github.com/itsjustriley)
8
+
9
+ - Fix broken `aria-label` on territory code input in address form. The label was the raw developer string `"territoryCode"` instead of a human-readable `"Country code"`. ([#3607](https://github.com/Shopify/hydrogen/pull/3607)) by [@itsjustriley](https://github.com/itsjustriley)
10
+
11
+ - Add aria-label to ProductPrice for improved screen reader accessibility ([#3558](https://github.com/Shopify/hydrogen/pull/3558)) by [@itsjustriley](https://github.com/itsjustriley)
12
+
13
+ - Updated dependencies [[`108243003a7f36349a446478f4e8ab0cade3e13a`](https://github.com/Shopify/hydrogen/commit/108243003a7f36349a446478f4e8ab0cade3e13a)]:
14
+ - @shopify/hydrogen@2026.1.3
15
+
16
+ ## 2026.1.2
17
+
18
+ ### Patch Changes
19
+
20
+ - Improve gift card accessibility in Skeleton template ([#3518](https://github.com/Shopify/hydrogen/pull/3518)) by [@itsjustriley](https://github.com/itsjustriley)
21
+
22
+ - Updated shopify/cli dependencies for cli-hydrogen ([#3553](https://github.com/Shopify/hydrogen/pull/3553)) by [@andguy95](https://github.com/andguy95)
23
+
24
+ - Updated loaders that used `customerAccount.handleAuthStatus()` to now await it. ([#3523](https://github.com/Shopify/hydrogen/pull/3523)) by [@fredericoo](https://github.com/fredericoo)
25
+
26
+ ### Migration
27
+
28
+ If you call `handleAuthStatus()` in your own loaders, update those callsites to use `await`.
29
+
30
+ - Updated dependencies [[`16b0e7baca0dfd1fb330d12dac924c7593d169a8`](https://github.com/Shopify/hydrogen/commit/16b0e7baca0dfd1fb330d12dac924c7593d169a8), [`1c19b87782818dbdb4252754d2d44eb9a44fe50f`](https://github.com/Shopify/hydrogen/commit/1c19b87782818dbdb4252754d2d44eb9a44fe50f), [`029fa2d0e2297f67b37c650ba8e875ee5dee81b3`](https://github.com/Shopify/hydrogen/commit/029fa2d0e2297f67b37c650ba8e875ee5dee81b3)]:
31
+ - @shopify/hydrogen@2026.1.2
32
+
3
33
  ## 2026.1.1
4
34
 
5
35
  ### Patch Changes
@@ -5,6 +5,7 @@ import {
5
5
  useEffect,
6
6
  useState,
7
7
  } from 'react';
8
+ import {useId} from 'react';
8
9
 
9
10
  type AsideType = 'search' | 'cart' | 'mobile' | 'closed';
10
11
  type AsideContextValue = {
@@ -34,7 +35,7 @@ export function Aside({
34
35
  }) {
35
36
  const {type: activeType, close} = useAside();
36
37
  const expanded = type === activeType;
37
-
38
+ const id = useId();
38
39
  useEffect(() => {
39
40
  const abortController = new AbortController();
40
41
 
@@ -57,11 +58,12 @@ export function Aside({
57
58
  aria-modal
58
59
  className={`overlay ${expanded ? 'expanded' : ''}`}
59
60
  role="dialog"
61
+ aria-labelledby={id}
60
62
  >
61
63
  <button className="close-outside" onClick={close} />
62
64
  <aside>
63
65
  <header>
64
- <h3>{heading}</h3>
66
+ <h3 id={id}>{heading}</h3>
65
67
  <button className="close reset" onClick={close} aria-label="Close">
66
68
  &times;
67
69
  </button>
@@ -1,4 +1,4 @@
1
- import {useOptimisticCart, type OptimisticCartLine} from '@shopify/hydrogen';
1
+ import {useOptimisticCart} from '@shopify/hydrogen';
2
2
  import {Link} from 'react-router';
3
3
  import type {CartApiQueryFragment} from 'storefrontapi.generated';
4
4
  import {useAside} from '~/components/Aside';
@@ -50,7 +50,10 @@ export function CartMain({layout, cart: originalCart}: CartMainProps) {
50
50
  const childrenMap = getLineItemChildrenMap(cart?.lines?.nodes ?? []);
51
51
 
52
52
  return (
53
- <div className={className}>
53
+ <section
54
+ className={className}
55
+ aria-label={layout === 'page' ? 'Cart page' : 'Cart drawer'}
56
+ >
54
57
  <CartEmpty hidden={linesCount} layout={layout} />
55
58
  <div className="cart-details">
56
59
  <p id="cart-lines" className="sr-only">
@@ -79,7 +82,7 @@ export function CartMain({layout, cart: originalCart}: CartMainProps) {
79
82
  </div>
80
83
  {cartHasItems && <CartSummary cart={cart} layout={layout} />}
81
84
  </div>
82
- </div>
85
+ </section>
83
86
  );
84
87
  }
85
88
 
@@ -1,7 +1,7 @@
1
1
  import type {CartApiQueryFragment} from 'storefrontapi.generated';
2
2
  import type {CartLayout} from '~/components/CartMain';
3
3
  import {CartForm, Money, type OptimisticCart} from '@shopify/hydrogen';
4
- import {useEffect, useRef} from 'react';
4
+ import {useEffect, useId, useRef, useState} from 'react';
5
5
  import {useFetcher} from 'react-router';
6
6
 
7
7
  type CartSummaryProps = {
@@ -12,11 +12,16 @@ type CartSummaryProps = {
12
12
  export function CartSummary({cart, layout}: CartSummaryProps) {
13
13
  const className =
14
14
  layout === 'page' ? 'cart-summary-page' : 'cart-summary-aside';
15
+ const summaryId = useId();
16
+ const discountsHeadingId = useId();
17
+ const discountCodeInputId = useId();
18
+ const giftCardHeadingId = useId();
19
+ const giftCardInputId = useId();
15
20
 
16
21
  return (
17
- <div aria-labelledby="cart-summary" className={className}>
18
- <h4>Totals</h4>
19
- <dl className="cart-subtotal">
22
+ <div aria-labelledby={summaryId} className={className}>
23
+ <h4 id={summaryId}>Totals</h4>
24
+ <dl role="group" className="cart-subtotal">
20
25
  <dt>Subtotal</dt>
21
26
  <dd>
22
27
  {cart?.cost?.subtotalAmount?.amount ? (
@@ -26,8 +31,16 @@ export function CartSummary({cart, layout}: CartSummaryProps) {
26
31
  )}
27
32
  </dd>
28
33
  </dl>
29
- <CartDiscounts discountCodes={cart?.discountCodes} />
30
- <CartGiftCard giftCardCodes={cart?.appliedGiftCards} />
34
+ <CartDiscounts
35
+ discountCodes={cart?.discountCodes}
36
+ discountsHeadingId={discountsHeadingId}
37
+ discountCodeInputId={discountCodeInputId}
38
+ />
39
+ <CartGiftCard
40
+ giftCardCodes={cart?.appliedGiftCards}
41
+ giftCardHeadingId={giftCardHeadingId}
42
+ giftCardInputId={giftCardInputId}
43
+ />
31
44
  <CartCheckoutActions checkoutUrl={cart?.checkoutUrl} />
32
45
  </div>
33
46
  );
@@ -48,8 +61,12 @@ function CartCheckoutActions({checkoutUrl}: {checkoutUrl?: string}) {
48
61
 
49
62
  function CartDiscounts({
50
63
  discountCodes,
64
+ discountsHeadingId,
65
+ discountCodeInputId,
51
66
  }: {
52
67
  discountCodes?: CartApiQueryFragment['discountCodes'];
68
+ discountsHeadingId: string;
69
+ discountCodeInputId: string;
53
70
  }) {
54
71
  const codes: string[] =
55
72
  discountCodes
@@ -57,13 +74,17 @@ function CartDiscounts({
57
74
  ?.map(({code}) => code) || [];
58
75
 
59
76
  return (
60
- <div>
77
+ <section aria-label="Discounts">
61
78
  {/* Have existing discount, display it with a remove option */}
62
79
  <dl hidden={!codes.length}>
63
80
  <div>
64
- <dt>Discount(s)</dt>
81
+ <dt id={discountsHeadingId}>Discounts</dt>
65
82
  <UpdateDiscountForm>
66
- <div className="cart-discount">
83
+ <div
84
+ className="cart-discount"
85
+ role="group"
86
+ aria-labelledby={discountsHeadingId}
87
+ >
67
88
  <code>{codes?.join(', ')}</code>
68
89
  &nbsp;
69
90
  <button type="submit" aria-label="Remove discount">
@@ -77,11 +98,11 @@ function CartDiscounts({
77
98
  {/* Show an input to apply a discount */}
78
99
  <UpdateDiscountForm discountCodes={codes}>
79
100
  <div>
80
- <label htmlFor="discount-code-input" className="sr-only">
101
+ <label htmlFor={discountCodeInputId} className="sr-only">
81
102
  Discount code
82
103
  </label>
83
104
  <input
84
- id="discount-code-input"
105
+ id={discountCodeInputId}
85
106
  type="text"
86
107
  name="discountCode"
87
108
  placeholder="Discount code"
@@ -92,7 +113,7 @@ function CartDiscounts({
92
113
  </button>
93
114
  </div>
94
115
  </UpdateDiscountForm>
95
- </div>
116
+ </section>
96
117
  );
97
118
  }
98
119
 
@@ -118,52 +139,110 @@ function UpdateDiscountForm({
118
139
 
119
140
  function CartGiftCard({
120
141
  giftCardCodes,
142
+ giftCardHeadingId,
143
+ giftCardInputId,
121
144
  }: {
122
145
  giftCardCodes: CartApiQueryFragment['appliedGiftCards'] | undefined;
146
+ giftCardHeadingId: string;
147
+ giftCardInputId: string;
123
148
  }) {
124
149
  const giftCardCodeInput = useRef<HTMLInputElement>(null);
150
+ const removeButtonRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
151
+ const previousCardIdsRef = useRef<string[]>([]);
125
152
  const giftCardAddFetcher = useFetcher({key: 'gift-card-add'});
153
+ const [removedCardIndex, setRemovedCardIndex] = useState<number | null>(null);
126
154
 
127
155
  useEffect(() => {
128
156
  if (giftCardAddFetcher.data) {
129
- giftCardCodeInput.current!.value = '';
157
+ if (giftCardCodeInput.current !== null) {
158
+ giftCardCodeInput.current.value = '';
159
+ }
130
160
  }
131
161
  }, [giftCardAddFetcher.data]);
132
162
 
163
+ useEffect(() => {
164
+ const currentCardIds = giftCardCodes?.map((card) => card.id) || [];
165
+
166
+ if (removedCardIndex !== null && giftCardCodes) {
167
+ const focusTargetIndex = Math.min(
168
+ removedCardIndex,
169
+ giftCardCodes.length - 1,
170
+ );
171
+ const focusTargetCard = giftCardCodes[focusTargetIndex];
172
+ const focusButton = focusTargetCard
173
+ ? removeButtonRefs.current.get(focusTargetCard.id)
174
+ : null;
175
+
176
+ if (focusButton) {
177
+ focusButton.focus();
178
+ } else if (giftCardCodeInput.current) {
179
+ giftCardCodeInput.current.focus();
180
+ }
181
+
182
+ setRemovedCardIndex(null);
183
+ }
184
+
185
+ previousCardIdsRef.current = currentCardIds;
186
+ }, [giftCardCodes, removedCardIndex]);
187
+
188
+ const handleRemoveClick = (cardId: string) => {
189
+ const index = previousCardIdsRef.current.indexOf(cardId);
190
+ if (index !== -1) {
191
+ setRemovedCardIndex(index);
192
+ }
193
+ };
194
+
133
195
  return (
134
- <div>
196
+ <section aria-label="Gift cards">
135
197
  {giftCardCodes && giftCardCodes.length > 0 && (
136
198
  <dl>
137
- <dt>Applied Gift Card(s)</dt>
199
+ <dt id={giftCardHeadingId}>Applied Gift Card(s)</dt>
138
200
  {giftCardCodes.map((giftCard) => (
139
- <RemoveGiftCardForm key={giftCard.id} giftCardId={giftCard.id}>
140
- <div className="cart-discount">
201
+ <dd key={giftCard.id} className="cart-discount">
202
+ <RemoveGiftCardForm
203
+ giftCardId={giftCard.id}
204
+ lastCharacters={giftCard.lastCharacters}
205
+ onRemoveClick={() => handleRemoveClick(giftCard.id)}
206
+ buttonRef={(el: HTMLButtonElement | null) => {
207
+ if (el) {
208
+ removeButtonRefs.current.set(giftCard.id, el);
209
+ } else {
210
+ removeButtonRefs.current.delete(giftCard.id);
211
+ }
212
+ }}
213
+ >
141
214
  <code>***{giftCard.lastCharacters}</code>
142
215
  &nbsp;
143
216
  <Money data={giftCard.amountUsed} />
144
- &nbsp;
145
- <button type="submit">Remove</button>
146
- </div>
147
- </RemoveGiftCardForm>
217
+ </RemoveGiftCardForm>
218
+ </dd>
148
219
  ))}
149
220
  </dl>
150
221
  )}
151
222
 
152
223
  <AddGiftCardForm fetcherKey="gift-card-add">
153
224
  <div>
225
+ <label htmlFor={giftCardInputId} className="sr-only">
226
+ Gift card code
227
+ </label>
154
228
  <input
229
+ id={giftCardInputId}
155
230
  type="text"
156
231
  name="giftCardCode"
157
232
  placeholder="Gift card code"
158
233
  ref={giftCardCodeInput}
159
234
  />
160
235
  &nbsp;
161
- <button type="submit" disabled={giftCardAddFetcher.state !== 'idle'}>
236
+ <button
237
+ type="submit"
238
+ disabled={giftCardAddFetcher.state !== 'idle'}
239
+ aria-label="Apply gift card code"
240
+ >
162
241
  Apply
163
242
  </button>
164
243
  </div>
165
244
  </AddGiftCardForm>
166
- </div>
245
+ </section>
167
246
  );
168
247
  }
169
248
 
@@ -187,10 +266,16 @@ function AddGiftCardForm({
187
266
 
188
267
  function RemoveGiftCardForm({
189
268
  giftCardId,
269
+ lastCharacters,
190
270
  children,
271
+ onRemoveClick,
272
+ buttonRef,
191
273
  }: {
192
274
  giftCardId: string;
275
+ lastCharacters: string;
193
276
  children: React.ReactNode;
277
+ onRemoveClick?: () => void;
278
+ buttonRef?: (el: HTMLButtonElement | null) => void;
194
279
  }) {
195
280
  return (
196
281
  <CartForm
@@ -201,6 +286,15 @@ function RemoveGiftCardForm({
201
286
  }}
202
287
  >
203
288
  {children}
289
+ &nbsp;
290
+ <button
291
+ type="submit"
292
+ aria-label={`Remove gift card ending in ${lastCharacters}`}
293
+ onClick={onRemoveClick}
294
+ ref={buttonRef}
295
+ >
296
+ Remove
297
+ </button>
204
298
  </CartForm>
205
299
  );
206
300
  }
@@ -136,7 +136,7 @@ function SearchToggle() {
136
136
  );
137
137
  }
138
138
 
139
- function CartBadge({count}: {count: number | null}) {
139
+ function CartBadge({count}: {count: number}) {
140
140
  const {open} = useAside();
141
141
  const {publish, shop, cart, prevCart} = useAnalytics();
142
142
 
@@ -154,14 +154,14 @@ function CartBadge({count}: {count: number | null}) {
154
154
  } as CartViewPayload);
155
155
  }}
156
156
  >
157
- Cart {count === null ? <span>&nbsp;</span> : count}
157
+ Cart <span aria-label={`(items: ${count})`}>{count}</span>
158
158
  </a>
159
159
  );
160
160
  }
161
161
 
162
162
  function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
163
163
  return (
164
- <Suspense fallback={<CartBadge count={null} />}>
164
+ <Suspense fallback={<CartBadge count={0} />}>
165
165
  <Await resolve={cart}>
166
166
  <CartBanner />
167
167
  </Await>
@@ -2,15 +2,17 @@ import * as React from 'react';
2
2
  import {Pagination} from '@shopify/hydrogen';
3
3
 
4
4
  /**
5
- * <PaginatedResourceSection > is a component that encapsulate how the previous and next behaviors throughout your application.
5
+ * <PaginatedResourceSection> encapsulates the previous and next pagination behaviors throughout your application.
6
6
  */
7
7
  export function PaginatedResourceSection<NodesType>({
8
8
  connection,
9
9
  children,
10
+ ariaLabel,
10
11
  resourcesClassName,
11
12
  }: {
12
13
  connection: React.ComponentProps<typeof Pagination<NodesType>>['connection'];
13
14
  children: React.FunctionComponent<{node: NodesType; index: number}>;
15
+ ariaLabel?: string;
14
16
  resourcesClassName?: string;
15
17
  }) {
16
18
  return (
@@ -23,15 +25,33 @@ export function PaginatedResourceSection<NodesType>({
23
25
  return (
24
26
  <div>
25
27
  <PreviousLink>
26
- {isLoading ? 'Loading...' : <span>↑ Load previous</span>}
28
+ {isLoading ? (
29
+ 'Loading...'
30
+ ) : (
31
+ <span>
32
+ <span aria-hidden="true">↑</span> Load previous
33
+ </span>
34
+ )}
27
35
  </PreviousLink>
28
36
  {resourcesClassName ? (
29
- <div className={resourcesClassName}>{resourcesMarkup}</div>
37
+ <div
38
+ aria-label={ariaLabel}
39
+ className={resourcesClassName}
40
+ role={ariaLabel ? 'region' : undefined}
41
+ >
42
+ {resourcesMarkup}
43
+ </div>
30
44
  ) : (
31
45
  resourcesMarkup
32
46
  )}
33
47
  <NextLink>
34
- {isLoading ? 'Loading...' : <span>Load more ↓</span>}
48
+ {isLoading ? (
49
+ 'Loading...'
50
+ ) : (
51
+ <span>
52
+ Load more <span aria-hidden="true">↓</span>
53
+ </span>
54
+ )}
35
55
  </NextLink>
36
56
  </div>
37
57
  );
@@ -9,7 +9,7 @@ export function ProductPrice({
9
9
  compareAtPrice?: MoneyV2 | null;
10
10
  }) {
11
11
  return (
12
- <div className="product-price">
12
+ <div aria-label="Price" className="product-price" role="group">
13
13
  {compareAtPrice ? (
14
14
  <div className="product-price-on-sale">
15
15
  {price ? <Money data={price} /> : null}
@@ -1,13 +1,8 @@
1
1
  import type {Route} from './+types/[robots.txt]';
2
- import {parseGid} from '@shopify/hydrogen';
3
2
 
4
- export async function loader({request, context}: Route.LoaderArgs) {
3
+ export function loader({request}: Route.LoaderArgs) {
5
4
  const url = new URL(request.url);
6
-
7
- const {shop} = await context.storefront.query(ROBOTS_QUERY);
8
-
9
- const shopId = parseGid(shop.id).id;
10
- const body = robotsTxtData({url: url.origin, shopId});
5
+ const body = robotsTxtData({url: url.origin});
11
6
 
12
7
  return new Response(body, {
13
8
  status: 200,
@@ -19,35 +14,31 @@ export async function loader({request, context}: Route.LoaderArgs) {
19
14
  });
20
15
  }
21
16
 
22
- function robotsTxtData({url, shopId}: {shopId?: string; url?: string}) {
17
+ function robotsTxtData({url}: {url?: string}) {
23
18
  const sitemapUrl = url ? `${url}/sitemap.xml` : undefined;
24
19
 
25
20
  return `
26
21
  User-agent: *
27
- ${generalDisallowRules({sitemapUrl, shopId})}
22
+ ${generalDisallowRules({sitemapUrl})}
28
23
 
29
24
  # Google adsbot ignores robots.txt unless specifically named!
30
25
  User-agent: adsbot-google
31
- Disallow: /checkouts/
32
- Disallow: /checkout
33
- Disallow: /carts
34
- Disallow: /orders
35
- ${shopId ? `Disallow: /${shopId}/checkouts` : ''}
36
- ${shopId ? `Disallow: /${shopId}/orders` : ''}
37
- Disallow: /*?*oseid=*
38
- Disallow: /*preview_theme_id*
39
- Disallow: /*preview_script_id*
26
+ Disallow: /cart
27
+ Disallow: /account
28
+ Disallow: /search
29
+ Allow: /search/
30
+ Disallow: /search/?*
40
31
 
41
32
  User-agent: Nutch
42
33
  Disallow: /
43
34
 
44
35
  User-agent: AhrefsBot
45
36
  Crawl-delay: 10
46
- ${generalDisallowRules({sitemapUrl, shopId})}
37
+ ${generalDisallowRules({sitemapUrl})}
47
38
 
48
39
  User-agent: AhrefsSiteAudit
49
40
  Crawl-delay: 10
50
- ${generalDisallowRules({sitemapUrl, shopId})}
41
+ ${generalDisallowRules({sitemapUrl})}
51
42
 
52
43
  User-agent: MJ12bot
53
44
  Crawl-Delay: 10
@@ -61,21 +52,8 @@ Crawl-delay: 1
61
52
  * This function generates disallow rules that generally follow what Shopify's
62
53
  * Online Store has as defaults for their robots.txt
63
54
  */
64
- function generalDisallowRules({
65
- shopId,
66
- sitemapUrl,
67
- }: {
68
- shopId?: string;
69
- sitemapUrl?: string;
70
- }) {
71
- return `Disallow: /admin
72
- Disallow: /cart
73
- Disallow: /orders
74
- Disallow: /checkouts/
75
- Disallow: /checkout
76
- ${shopId ? `Disallow: /${shopId}/checkouts` : ''}
77
- ${shopId ? `Disallow: /${shopId}/orders` : ''}
78
- Disallow: /carts
55
+ function generalDisallowRules({sitemapUrl}: {sitemapUrl?: string}) {
56
+ return `Disallow: /cart
79
57
  Disallow: /account
80
58
  Disallow: /collections/*sort_by*
81
59
  Disallow: /*/collections/*sort_by*
@@ -85,33 +63,16 @@ Disallow: /collections/*%2b*
85
63
  Disallow: /*/collections/*+*
86
64
  Disallow: /*/collections/*%2B*
87
65
  Disallow: /*/collections/*%2b*
88
- Disallow: */collections/*filter*&*filter*
66
+ Disallow: /*/collections/*filter*&*filter*
89
67
  Disallow: /blogs/*+*
90
68
  Disallow: /blogs/*%2B*
91
69
  Disallow: /blogs/*%2b*
92
70
  Disallow: /*/blogs/*+*
93
71
  Disallow: /*/blogs/*%2B*
94
72
  Disallow: /*/blogs/*%2b*
95
- Disallow: /*?*oseid=*
96
- Disallow: /*preview_theme_id*
97
- Disallow: /*preview_script_id*
98
73
  Disallow: /policies/
99
- Disallow: /*/*?*ls=*&ls=*
100
- Disallow: /*/*?*ls%3D*%3Fls%3D*
101
- Disallow: /*/*?*ls%3d*%3fls%3d*
102
74
  Disallow: /search
103
75
  Allow: /search/
104
76
  Disallow: /search/?*
105
- Disallow: /apple-app-site-association
106
- Disallow: /.well-known/shopify/monorail
107
77
  ${sitemapUrl ? `Sitemap: ${sitemapUrl}` : ''}`;
108
78
  }
109
-
110
- const ROBOTS_QUERY = `#graphql
111
- query StoreRobots($country: CountryCode, $language: LanguageCode)
112
- @inContext(country: $country, language: $language) {
113
- shop {
114
- id
115
- }
116
- }
117
- ` as const;
@@ -83,7 +83,11 @@ function FeaturedCollection({
83
83
  >
84
84
  {image && (
85
85
  <div className="featured-collection-image">
86
- <Image data={image} sizes="100vw" />
86
+ <Image
87
+ data={image}
88
+ sizes="100vw"
89
+ alt={image.altText || collection.title}
90
+ />
87
91
  </div>
88
92
  )}
89
93
  <h1>{collection.title}</h1>
@@ -97,8 +101,11 @@ function RecommendedProducts({
97
101
  products: Promise<RecommendedProductsQuery | null>;
98
102
  }) {
99
103
  return (
100
- <div className="recommended-products">
101
- <h2>Recommended Products</h2>
104
+ <section
105
+ className="recommended-products"
106
+ aria-labelledby="recommended-products"
107
+ >
108
+ <h2 id="recommended-products">Recommended Products</h2>
102
109
  <Suspense fallback={<div>Loading...</div>}>
103
110
  <Await resolve={products}>
104
111
  {(response) => (
@@ -113,7 +120,7 @@ function RecommendedProducts({
113
120
  </Await>
114
121
  </Suspense>
115
122
  <br />
116
- </div>
123
+ </section>
117
124
  );
118
125
  }
119
126
 
@@ -3,7 +3,7 @@ import type {Route} from './+types/account.$';
3
3
 
4
4
  // fallback wild card for all unauthenticated routes in account section
5
5
  export async function loader({context}: Route.LoaderArgs) {
6
- context.customerAccount.handleAuthStatus();
6
+ await context.customerAccount.handleAuthStatus();
7
7
 
8
8
  return redirect('/account');
9
9
  }
@@ -32,7 +32,7 @@ export const meta: Route.MetaFunction = () => {
32
32
  };
33
33
 
34
34
  export async function loader({context}: Route.LoaderArgs) {
35
- context.customerAccount.handleAuthStatus();
35
+ await context.customerAccount.handleAuthStatus();
36
36
 
37
37
  return {};
38
38
  }
@@ -468,7 +468,7 @@ export function AddressForm({
468
468
  />
469
469
  <label htmlFor="territoryCode">Country Code*</label>
470
470
  <input
471
- aria-label="territoryCode"
471
+ aria-label="Country code"
472
472
  autoComplete="country"
473
473
  defaultValue={address?.territoryCode ?? ''}
474
474
  id="territoryCode"
@@ -20,7 +20,7 @@ export const meta: Route.MetaFunction = () => {
20
20
  };
21
21
 
22
22
  export async function loader({context}: Route.LoaderArgs) {
23
- context.customerAccount.handleAuthStatus();
23
+ await context.customerAccount.handleAuthStatus();
24
24
 
25
25
  return {};
26
26
  }
@@ -2,7 +2,7 @@
2
2
  "name": "skeleton",
3
3
  "private": true,
4
4
  "sideEffects": false,
5
- "version": "2026.1.1",
5
+ "version": "2026.1.3",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "build": "shopify hydrogen build --codegen",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "prettier": "@shopify/prettier-config",
16
16
  "dependencies": {
17
- "@shopify/hydrogen": "*2026.1.1",
17
+ "@shopify/hydrogen": "2026.1.3",
18
18
  "graphql": "^16.10.0",
19
19
  "graphql-tag": "^2.12.6",
20
20
  "isbot": "^5.1.22",
@@ -30,9 +30,9 @@
30
30
  "@graphql-codegen/cli": "5.0.2",
31
31
  "@react-router/dev": "7.12.0",
32
32
  "@react-router/fs-routes": "7.12.0",
33
- "@shopify/cli": "3.85.4",
33
+ "@shopify/cli": "3.91.1",
34
34
  "@shopify/hydrogen-codegen": "0.3.3",
35
- "@shopify/mini-oxygen": "4.0.1",
35
+ "@shopify/mini-oxygen": "4.0.2",
36
36
  "@shopify/oxygen-workers-types": "^4.1.6",
37
37
  "@shopify/prettier-config": "^1.1.2",
38
38
  "@total-typescript/ts-reset": "^0.6.1",
@@ -377,13 +377,6 @@ export type FooterQuery = {
377
377
  >;
378
378
  };
379
379
 
380
- export type StoreRobotsQueryVariables = StorefrontAPI.Exact<{
381
- country?: StorefrontAPI.InputMaybe<StorefrontAPI.CountryCode>;
382
- language?: StorefrontAPI.InputMaybe<StorefrontAPI.LanguageCode>;
383
- }>;
384
-
385
- export type StoreRobotsQuery = {shop: Pick<StorefrontAPI.Shop, 'id'>};
386
-
387
380
  export type FeaturedCollectionFragment = Pick<
388
381
  StorefrontAPI.Collection,
389
382
  'id' | 'title' | 'handle'
@@ -1283,10 +1276,6 @@ interface GeneratedQueryTypes {
1283
1276
  return: FooterQuery;
1284
1277
  variables: FooterQueryVariables;
1285
1278
  };
1286
- '#graphql\n query StoreRobots($country: CountryCode, $language: LanguageCode)\n @inContext(country: $country, language: $language) {\n shop {\n id\n }\n }\n': {
1287
- return: StoreRobotsQuery;
1288
- variables: StoreRobotsQueryVariables;
1289
- };
1290
1279
  '#graphql\n fragment FeaturedCollection on Collection {\n id\n title\n image {\n id\n url\n altText\n width\n height\n }\n handle\n }\n query FeaturedCollection($country: CountryCode, $language: LanguageCode)\n @inContext(country: $country, language: $language) {\n collections(first: 1, sortKey: UPDATED_AT, reverse: true) {\n nodes {\n ...FeaturedCollection\n }\n }\n }\n': {
1291
1280
  return: FeaturedCollectionQuery;
1292
1281
  variables: FeaturedCollectionQueryVariables;
@@ -8,7 +8,7 @@ export default defineConfig({
8
8
  plugins: [hydrogen(), oxygen(), reactRouter(), tsconfigPaths()],
9
9
  build: {
10
10
  // Allow a strict Content-Security-Policy
11
- // withtout inlining assets as base64:
11
+ // without inlining assets as base64:
12
12
  assetsInlineLimit: 0,
13
13
  },
14
14
  ssr: {
@@ -21,7 +21,7 @@ export default defineConfig({
21
21
  ],
22
22
  build: {
23
23
  // Allow a strict Content-Security-Policy
24
- // withtout inlining assets as base64:
24
+ // without inlining assets as base64:
25
25
  assetsInlineLimit: 0,
26
26
  },
27
27
  ssr: {
@@ -7,7 +7,7 @@ import { renderSuccess, renderInfo } from '@shopify/cli-kit/node/ui';
7
7
  import { AbortError } from '@shopify/cli-kit/node/error';
8
8
  import { removeFile } from '@shopify/cli-kit/node/fs';
9
9
  import { setH2OVerbose, isH2Verbose, muteDevLogs, enhanceH2Logs } from '../../lib/log.js';
10
- import { commonFlags, overrideFlag, flagsToCamelObject, DEFAULT_INSPECTOR_PORT, DEFAULT_APP_PORT } from '../../lib/flags.js';
10
+ import { commonFlags, overrideFlag, flagsToCamelObject, DEFAULT_APP_PORT, DEFAULT_INSPECTOR_PORT } from '../../lib/flags.js';
11
11
  import { spawnCodegenProcess } from '../../lib/codegen.js';
12
12
  import { getAllEnvironmentVariables } from '../../lib/environment-variables.js';
13
13
  import { displayDevUpgradeNotice } from './upgrade.js';
@@ -103,6 +103,9 @@ async function runDev({
103
103
  if (!process.env.NODE_ENV) process.env.NODE_ENV = "development";
104
104
  if (verbose) setH2OVerbose();
105
105
  if (!isH2Verbose()) muteDevLogs();
106
+ if (appPort === 0) {
107
+ appPort = await findPort(DEFAULT_APP_PORT);
108
+ }
106
109
  const root = appPath ?? process.cwd();
107
110
  const cliCommandPromise = getCliCommand(root);
108
111
  const backgroundPromise = getDevConfigInBackground(
@@ -6,7 +6,7 @@ import { ensureInsideGitDirectory, isClean } from '@shopify/cli-kit/node/git';
6
6
  import Command from '@shopify/cli-kit/node/base-command';
7
7
  import { renderSuccess, renderInfo, renderConfirmationPrompt, renderTasks, renderSelectPrompt, renderWarning } from '@shopify/cli-kit/node/ui';
8
8
  import { isDirectory, readFile, mkdir, fileExists, touchFile, removeFile, writeFile } from '@shopify/cli-kit/node/fs';
9
- import { getPackageManager, getDependencies, installNodeModules } from '@shopify/cli-kit/node/node-package-manager';
9
+ import { getPackageManager, getDependencies } from '@shopify/cli-kit/node/node-package-manager';
10
10
  import { exec } from '@shopify/cli-kit/node/system';
11
11
  import { AbortError } from '@shopify/cli-kit/node/error';
12
12
  import { resolvePath, joinPath, dirname } from '@shopify/cli-kit/node/path';
@@ -106,7 +106,7 @@ async function runUpgrade({
106
106
  availableUpgrades,
107
107
  currentDependencies
108
108
  });
109
- cumulativeRelease = getCummulativeRelease({
109
+ cumulativeRelease = getCumulativeRelease({
110
110
  availableUpgrades,
111
111
  currentVersion,
112
112
  currentDependencies,
@@ -128,7 +128,11 @@ async function runUpgrade({
128
128
  appPath,
129
129
  selectedRelease,
130
130
  currentDependencies,
131
- targetVersion
131
+ targetVersion,
132
+ cumulativeRemoveDependencies: cumulativeRelease.removeDependencies,
133
+ cumulativeRemoveDevDependencies: cumulativeRelease.removeDevDependencies,
134
+ cumulativeDependencies: cumulativeRelease.dependencies,
135
+ cumulativeDevDependencies: cumulativeRelease.devDependencies
132
136
  });
133
137
  await validateUpgrade({
134
138
  appPath,
@@ -336,20 +340,31 @@ async function getSelectedRelease({
336
340
  ) : void 0;
337
341
  return targetRelease ?? promptUpgradeOptions(currentVersion, availableUpgrades);
338
342
  }
339
- function getCummulativeRelease({
343
+ function getCumulativeRelease({
340
344
  availableUpgrades,
341
345
  selectedRelease,
342
346
  currentVersion,
343
347
  currentDependencies
344
348
  }) {
345
349
  const currentPinnedVersion = getAbsoluteVersion(currentVersion);
346
- if (!availableUpgrades?.length) {
347
- return { features: [], fixes: [] };
348
- }
350
+ const empty = {
351
+ features: [],
352
+ fixes: [],
353
+ removeDependencies: [],
354
+ removeDevDependencies: [],
355
+ dependencies: {},
356
+ devDependencies: {}
357
+ };
358
+ if (!availableUpgrades?.length) return empty;
349
359
  if (selectedRelease.dependencies?.["@shopify/hydrogen"] === "next") {
350
360
  return {
361
+ ...empty,
351
362
  features: selectedRelease.features || [],
352
- fixes: selectedRelease.fixes || []
363
+ fixes: selectedRelease.fixes || [],
364
+ removeDependencies: selectedRelease.removeDependencies ?? [],
365
+ removeDevDependencies: selectedRelease.removeDevDependencies ?? [],
366
+ dependencies: selectedRelease.dependencies ?? {},
367
+ devDependencies: selectedRelease.devDependencies ?? {}
353
368
  };
354
369
  }
355
370
  const upgradingReleases = availableUpgrades.filter((release) => {
@@ -359,14 +374,65 @@ function getCummulativeRelease({
359
374
  if (!isSameHydrogenVersion || !currentDependencies) return false;
360
375
  return hasOutdatedDependencies({ release, currentDependencies });
361
376
  });
362
- return upgradingReleases.reduce(
363
- (acc, release) => {
364
- acc.features = [...acc.features, ...release.features];
365
- acc.fixes = [...acc.fixes, ...release.fixes];
366
- return acc;
367
- },
368
- { features: [], fixes: [] }
377
+ const features = upgradingReleases.flatMap((r) => r.features);
378
+ const fixes = upgradingReleases.flatMap((r) => r.fixes);
379
+ const releasesByVersion = [...upgradingReleases].sort(
380
+ (a, b) => semver.compare(a.version, b.version)
369
381
  );
382
+ const removedDepsAt = /* @__PURE__ */ new Map();
383
+ const removedDevDepsAt = /* @__PURE__ */ new Map();
384
+ releasesByVersion.forEach((release, i) => {
385
+ release.removeDependencies?.forEach((dep) => {
386
+ removedDepsAt.set(dep, i);
387
+ });
388
+ release.removeDevDependencies?.forEach((dep) => {
389
+ removedDevDepsAt.set(dep, i);
390
+ });
391
+ });
392
+ const reinstalledDeps = /* @__PURE__ */ new Set();
393
+ const reinstalledDevDeps = /* @__PURE__ */ new Set();
394
+ for (let i = 0; i < releasesByVersion.length; i++) {
395
+ const release = releasesByVersion[i];
396
+ if (!release) continue;
397
+ const dependencies2 = release.dependencies ?? {};
398
+ const devDependencies2 = release.devDependencies ?? {};
399
+ Object.keys(dependencies2).forEach((dep) => {
400
+ const removalI = removedDepsAt.get(dep);
401
+ if (removalI !== void 0 && i >= removalI) {
402
+ reinstalledDeps.add(dep);
403
+ }
404
+ });
405
+ Object.keys(devDependencies2).forEach((dep) => {
406
+ const removalI = removedDevDepsAt.get(dep);
407
+ if (removalI !== void 0 && i >= removalI) {
408
+ reinstalledDevDeps.add(dep);
409
+ }
410
+ });
411
+ }
412
+ const removeDependencies = [
413
+ ...new Set(
414
+ releasesByVersion.flatMap((r) => r.removeDependencies ?? []).filter((dep) => !reinstalledDeps.has(dep))
415
+ )
416
+ ];
417
+ const removeDevDependencies = [
418
+ ...new Set(
419
+ releasesByVersion.flatMap((r) => r.removeDevDependencies ?? []).filter((dep) => !reinstalledDevDeps.has(dep))
420
+ )
421
+ ];
422
+ const dependencies = {};
423
+ const devDependencies = {};
424
+ for (const release of releasesByVersion) {
425
+ Object.assign(dependencies, release.dependencies ?? {});
426
+ Object.assign(devDependencies, release.devDependencies ?? {});
427
+ }
428
+ return {
429
+ features,
430
+ fixes,
431
+ removeDependencies,
432
+ removeDevDependencies,
433
+ dependencies,
434
+ devDependencies
435
+ };
370
436
  }
371
437
  function displayConfirmation({
372
438
  cumulativeRelease,
@@ -377,7 +443,7 @@ function displayConfirmation({
377
443
  if (features.length || fixes.length) {
378
444
  renderInfo({
379
445
  headline: `Included in this upgrade:`,
380
- //@ts-ignore we know that filter(Boolean) will always return an array
446
+ // @ts-expect-error - filter(Boolean) removes falsy values, leaving only objects
381
447
  customSections: [
382
448
  features.length && {
383
449
  title: "Features",
@@ -461,10 +527,20 @@ function maybeIncludeDependency({
461
527
  function buildUpgradeCommandArgs({
462
528
  selectedRelease,
463
529
  currentDependencies,
464
- targetVersion
530
+ targetVersion,
531
+ cumulativeDependencies,
532
+ cumulativeDevDependencies
465
533
  }) {
466
534
  const args = [];
467
- for (const dependency of Object.entries(selectedRelease.dependencies)) {
535
+ const effectiveDependencies = {
536
+ ...cumulativeDependencies ?? {},
537
+ ...selectedRelease.dependencies
538
+ };
539
+ const effectiveDevDependencies = {
540
+ ...cumulativeDevDependencies ?? {},
541
+ ...selectedRelease.devDependencies
542
+ };
543
+ for (const dependency of Object.entries(effectiveDependencies)) {
468
544
  const shouldUpgradeDep = maybeIncludeDependency({
469
545
  currentDependencies,
470
546
  dependency,
@@ -480,7 +556,7 @@ function buildUpgradeCommandArgs({
480
556
  )}`
481
557
  );
482
558
  }
483
- for (const dependency of Object.entries(selectedRelease.devDependencies)) {
559
+ for (const dependency of Object.entries(effectiveDevDependencies)) {
484
560
  const shouldUpgradeDep = maybeIncludeDependency({
485
561
  currentDependencies,
486
562
  dependency,
@@ -497,7 +573,7 @@ function buildUpgradeCommandArgs({
497
573
  );
498
574
  }
499
575
  const currentRemix = Object.entries(currentDependencies).find(isRemixDependency);
500
- const selectedRemix = Object.entries(selectedRelease.dependencies).find(
576
+ const selectedRemix = Object.entries(effectiveDependencies).find(
501
577
  isRemixDependency
502
578
  );
503
579
  if (currentRemix && selectedRemix) {
@@ -514,7 +590,7 @@ function buildUpgradeCommandArgs({
514
590
  const currentReactRouter = Object.entries(currentDependencies).find(
515
591
  isReactRouterDependency
516
592
  );
517
- const selectedReactRouter = Object.entries(selectedRelease.dependencies).find(
593
+ const selectedReactRouter = Object.entries(effectiveDependencies).find(
518
594
  isReactRouterDependency
519
595
  );
520
596
  if (selectedReactRouter) {
@@ -537,12 +613,16 @@ async function upgradeNodeModules({
537
613
  appPath,
538
614
  selectedRelease,
539
615
  currentDependencies,
540
- targetVersion
616
+ targetVersion,
617
+ cumulativeRemoveDependencies,
618
+ cumulativeRemoveDevDependencies,
619
+ cumulativeDependencies,
620
+ cumulativeDevDependencies
541
621
  }) {
542
622
  const tasks = [];
543
623
  const depsToRemove = [
544
- ...selectedRelease.removeDependencies || [],
545
- ...selectedRelease.removeDevDependencies || []
624
+ ...cumulativeRemoveDependencies,
625
+ ...cumulativeRemoveDevDependencies
546
626
  ].filter((dep) => dep in currentDependencies);
547
627
  if (depsToRemove.length > 0) {
548
628
  tasks.push({
@@ -559,17 +639,24 @@ async function upgradeNodeModules({
559
639
  const upgradeArgs = buildUpgradeCommandArgs({
560
640
  selectedRelease,
561
641
  currentDependencies,
562
- targetVersion
642
+ targetVersion,
643
+ cumulativeDependencies,
644
+ cumulativeDevDependencies
563
645
  });
564
646
  if (upgradeArgs.length > 0) {
565
647
  tasks.push({
566
648
  title: `Upgrading dependencies`,
567
649
  task: async () => {
568
- await installNodeModules({
569
- directory: appPath,
570
- packageManager: await getPackageManager(appPath),
571
- args: upgradeArgs
572
- });
650
+ const packageManager = await getPackageManager(appPath);
651
+ const command = packageManager === "npm" ? "install" : packageManager === "yarn" ? "add" : packageManager === "pnpm" ? "add" : packageManager === "bun" ? "install" : "install";
652
+ const extraArgs = packageManager === "npm" || packageManager === "unknown" ? ["--legacy-peer-deps"] : [];
653
+ await exec(
654
+ resolvePackageManagerName(packageManager),
655
+ [command, ...extraArgs, ...upgradeArgs],
656
+ {
657
+ cwd: appPath
658
+ }
659
+ );
573
660
  }
574
661
  });
575
662
  }
@@ -577,6 +664,9 @@ async function upgradeNodeModules({
577
664
  await renderTasks(tasks, {});
578
665
  }
579
666
  }
667
+ function resolvePackageManagerName(packageManager) {
668
+ return packageManager === "unknown" ? "npm" : packageManager;
669
+ }
580
670
  async function uninstallNodeModules({
581
671
  directory,
582
672
  packageManager,
@@ -584,8 +674,12 @@ async function uninstallNodeModules({
584
674
  }) {
585
675
  if (args.length === 0) return;
586
676
  const command = packageManager === "npm" ? "uninstall" : packageManager === "yarn" ? "remove" : packageManager === "pnpm" ? "remove" : packageManager === "bun" ? "remove" : "uninstall";
587
- const actualPackageManager = packageManager === "unknown" ? "npm" : packageManager;
588
- await exec(actualPackageManager, [command, ...args], { cwd: directory });
677
+ const extraArgs = packageManager === "npm" || packageManager === "unknown" ? ["--legacy-peer-deps"] : [];
678
+ await exec(
679
+ resolvePackageManagerName(packageManager),
680
+ [command, ...extraArgs, ...args],
681
+ { cwd: directory }
682
+ );
589
683
  }
590
684
  function appendRemixDependencies({
591
685
  currentDependencies,
@@ -954,4 +1048,4 @@ async function displayDevUpgradeNotice({
954
1048
  }
955
1049
  }
956
1050
 
957
- export { buildUpgradeCommandArgs, Upgrade as default, displayConfirmation, displayDevUpgradeNotice, getAbsoluteVersion, getAvailableUpgrades, getChangelog, getCummulativeRelease, getHydrogenVersion, getPackageVersion, getSelectedRelease, isRunningFromHydrogenMonorepo, runUpgrade, upgradeNodeModules, validateUpgrade };
1051
+ export { buildUpgradeCommandArgs, Upgrade as default, displayConfirmation, displayDevUpgradeNotice, getAbsoluteVersion, getAvailableUpgrades, getChangelog, getCumulativeRelease, getHydrogenVersion, getPackageVersion, getSelectedRelease, isRunningFromHydrogenMonorepo, runUpgrade, upgradeNodeModules, validateUpgrade };
@@ -277,6 +277,8 @@ async function handleDependencies(projectDir, controller, packageManagerFromFlag
277
277
  cancellationMessage: "No",
278
278
  abortSignal: controller.signal
279
279
  });
280
+ } else {
281
+ actualPackageManager = detectedPackageManager;
280
282
  }
281
283
  }
282
284
  if (isHydrogenMonorepo) {
@@ -0,0 +1,198 @@
1
+ import { readFileSync, existsSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ function isErrnoException(err) {
5
+ return err instanceof Error && "code" in err;
6
+ }
7
+ const MARKER = "// [hydrogen-monorepo-patch]";
8
+ function getRunJsPath(root) {
9
+ return resolve(root, "node_modules", "@shopify", "cli", "bin", "run.js");
10
+ }
11
+ function isPatchApplied(content) {
12
+ return content.includes(MARKER);
13
+ }
14
+ function generatePatchedContent() {
15
+ return `#!/usr/bin/env node
16
+ ${MARKER}
17
+
18
+ // Mirrors upstream @shopify/cli/bin/run.js behavior.
19
+ // This is not introduced by the patch \u2014 @shopify/cli ships with this line.
20
+ // Changing it here would create a behavioral divergence between dev and production.
21
+ process.removeAllListeners('warning')
22
+
23
+ // --- Monorepo detection ---
24
+ // Walk up from cwd to find the hydrogen monorepo root.
25
+ // We look for packages/cli/package.json with the correct package name \u2014
26
+ // if it matches, we know we're inside the monorepo and should load the
27
+ // local @shopify/cli-hydrogen
28
+ const {existsSync, readFileSync} = await import('node:fs');
29
+ const {resolve, dirname} = await import('node:path');
30
+
31
+ let monorepoRoot = null;
32
+ let dir = process.cwd();
33
+ while (true) {
34
+ const candidate = resolve(dir, 'packages', 'cli', 'package.json');
35
+ if (existsSync(candidate)) {
36
+ try {
37
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
38
+ if (pkg.name === '@shopify/cli-hydrogen') {
39
+ monorepoRoot = dir;
40
+ break;
41
+ }
42
+ } catch {
43
+ // Ignore malformed package.json and keep walking
44
+ }
45
+ }
46
+ const parent = dirname(dir);
47
+ if (parent === dir) break; // reached filesystem root
48
+ dir = parent;
49
+ }
50
+
51
+ if (monorepoRoot) {
52
+ // We're in the hydrogen monorepo. Start @shopify/cli normally but
53
+ // inject pluginAdditions so oclif loads @shopify/cli-hydrogen from
54
+ // the monorepo's workspace (packages/cli) instead of the version
55
+ // bundled inside @shopify/cli/dist.
56
+ //
57
+ // This uses the same pluginAdditions mechanism that ShopifyConfig
58
+ // uses internally
59
+ const {fileURLToPath} = await import('node:url');
60
+ const {Config, run, flush} = await import('@oclif/core');
61
+
62
+ // root must point to @shopify/cli's installed location so oclif
63
+ // can find its package.json, oclif config, and bundled commands.
64
+ const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
65
+
66
+ const c = '\\x1b[38;5;209m';
67
+ const d = '\\x1b[2m';
68
+ const r = '\\x1b[0m';
69
+ console.log('');
70
+ console.log(c + ' \u250C\u2500\u2500 hydrogen-monorepo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510' + r);
71
+ console.log(c + ' \u2502' + r + ' ' + c + '\u2502' + r);
72
+ console.log(c + ' \u2502' + r + ' Using local cli-hydrogen plugin from packages/cli ' + c + '\u2502' + r);
73
+ console.log(c + ' \u2502' + r + d + ' Bundled commands replaced with local source ' + r + c + '\u2502' + r);
74
+ console.log(c + ' \u2502' + r + ' ' + c + '\u2502' + r);
75
+ console.log(c + ' \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518' + r);
76
+ console.log('');
77
+
78
+ // Tell ShopifyConfig to skip its own monorepo detection since we
79
+ // are handling pluginAdditions ourselves.
80
+ process.env.IGNORE_HYDROGEN_MONOREPO = '1';
81
+
82
+ const config = new Config({
83
+ root: cliRoot,
84
+ // pluginAdditions tells oclif's plugin loader to read the
85
+ // monorepo root's package.json, find @shopify/cli-hydrogen in
86
+ // its dependencies (workspace:*), and load that as a core plugin.
87
+ // Because workspace:* symlinks to packages/cli, oclif loads the
88
+ // local source code \u2014 exactly what we want for development.
89
+ pluginAdditions: {
90
+ core: ['@shopify/cli-hydrogen'],
91
+ path: monorepoRoot,
92
+ },
93
+ // Skip the oclif manifest cache so commands are loaded fresh from
94
+ // disk rather than from a potentially stale oclif.manifest.json.
95
+ ignoreManifest: true,
96
+ });
97
+
98
+ await config.load();
99
+
100
+ // --- Post-load command replacement ---
101
+ // After loading, both the bundled hydrogen commands (from @shopify/cli's
102
+ // root plugin) and the local ones (from pluginAdditions) are registered.
103
+ // Since @shopify/cli@3.83.0 (which upgraded to oclif v4),
104
+ // determinePriority is no longer an overridable instance method, so we
105
+ // manually replace the bundled commands with the external plugin's
106
+ // versions using oclif's private _commands Map.
107
+ const externalPlugin = Array.from(config.plugins.values()).find(
108
+ (p) => p.name === '@shopify/cli-hydrogen' && !p.isRoot,
109
+ );
110
+
111
+ if (!externalPlugin) {
112
+ throw new Error(
113
+ '[hydrogen-monorepo] Could not find local @shopify/cli-hydrogen plugin. ' +
114
+ 'The patch may need updating for the current @shopify/cli version.'
115
+ );
116
+ }
117
+
118
+ const cmds = config._commands;
119
+ if (!cmds || !(cmds instanceof Map)) {
120
+ throw new Error(
121
+ '[hydrogen-monorepo] Cannot replace bundled commands \u2014 oclif internals changed. ' +
122
+ 'The patch may need updating for the current oclif version.'
123
+ );
124
+ }
125
+
126
+ // Delete bundled hydrogen commands (canonical IDs + aliases + hidden aliases)
127
+ for (const command of externalPlugin.commands) {
128
+ if (!command.id.startsWith('hydrogen')) continue;
129
+ cmds.delete(command.id);
130
+ for (const alias of [...(command.aliases ?? []), ...(command.hiddenAliases ?? [])]) {
131
+ cmds.delete(alias);
132
+ }
133
+ }
134
+ // Re-insert commands from the local plugin. loadCommands handles
135
+ // alias registration and command permutations correctly.
136
+ config.loadCommands(externalPlugin);
137
+
138
+ await run(process.argv.slice(2), config);
139
+ await flush();
140
+ } else {
141
+ // Not in the monorepo \u2014 run the standard @shopify/cli entrypoint.
142
+ const {default: runCLI} = await import('../dist/index.js');
143
+ runCLI({development: false});
144
+ }
145
+ `;
146
+ }
147
+ function generateOriginalContent() {
148
+ return `#!/usr/bin/env node
149
+
150
+ process.removeAllListeners('warning')
151
+
152
+ import runCLI from '../dist/index.js'
153
+
154
+ runCLI({development: false})
155
+ `;
156
+ }
157
+ function applyPatch(runJsPath) {
158
+ let current;
159
+ try {
160
+ current = readFileSync(runJsPath, "utf8");
161
+ } catch (err) {
162
+ if (isErrnoException(err) && err.code === "ENOENT") {
163
+ throw new Error(
164
+ `@shopify/cli is not installed (${runJsPath} not found). Run 'pnpm install' first.`
165
+ );
166
+ }
167
+ throw err;
168
+ }
169
+ if (isPatchApplied(current)) return false;
170
+ const backupPath = runJsPath + ".backup";
171
+ if (!existsSync(backupPath)) {
172
+ writeFileSync(backupPath, current);
173
+ }
174
+ writeFileSync(runJsPath, generatePatchedContent());
175
+ return true;
176
+ }
177
+ function removePatch(runJsPath) {
178
+ let current;
179
+ try {
180
+ current = readFileSync(runJsPath, "utf8");
181
+ } catch (err) {
182
+ if (isErrnoException(err) && err.code === "ENOENT") {
183
+ return false;
184
+ }
185
+ throw err;
186
+ }
187
+ if (!isPatchApplied(current)) return false;
188
+ const backupPath = runJsPath + ".backup";
189
+ const hasBackup = existsSync(backupPath);
190
+ const original = hasBackup ? readFileSync(backupPath, "utf8") : generateOriginalContent();
191
+ writeFileSync(runJsPath, original);
192
+ if (hasBackup) {
193
+ unlinkSync(backupPath);
194
+ }
195
+ return true;
196
+ }
197
+
198
+ export { MARKER, applyPatch, generateOriginalContent, generatePatchedContent, getRunJsPath, isPatchApplied, removePatch };
@@ -1,8 +1,14 @@
1
+ import { createReadStream } from 'node:fs';
1
2
  import { execFileSync } from 'node:child_process';
2
3
  import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
+ import { join, isAbsolute } from 'node:path';
5
+ import { pipeline } from 'node:stream/promises';
4
6
  import { tmpdir } from 'node:os';
7
+ import gunzipMaybe from 'gunzip-maybe';
8
+ import { extract } from 'tar-fs';
5
9
 
10
+ const WINDOWS_SHELL_OPTS = process.platform === "win32" ? { shell: true } : {};
11
+ const PNPM_PACK_TIMEOUT_IN_MS = 6e4;
6
12
  const DEPENDENCY_SECTIONS = [
7
13
  "dependencies",
8
14
  "devDependencies",
@@ -20,7 +26,9 @@ async function getPackedTemplatePackageJson(sourceTemplateDir) {
20
26
  ["pack", "--pack-destination", tempDir, "--json"],
21
27
  {
22
28
  cwd: sourceTemplateDir,
23
- encoding: "utf8"
29
+ encoding: "utf8",
30
+ timeout: PNPM_PACK_TIMEOUT_IN_MS,
31
+ ...WINDOWS_SHELL_OPTS
24
32
  }
25
33
  );
26
34
  const parsedResult = JSON.parse(rawPackResult.trim());
@@ -28,14 +36,15 @@ async function getPackedTemplatePackageJson(sourceTemplateDir) {
28
36
  if (!packedTarball) {
29
37
  throw new Error("pnpm pack did not return a tarball filename.");
30
38
  }
31
- const packedManifestRaw = execFileSync(
32
- "tar",
33
- [
34
- "-xOf",
35
- packedTarball.startsWith("/") ? packedTarball : join(tempDir, packedTarball),
36
- "package/package.json"
37
- ],
38
- { encoding: "utf8" }
39
+ const tarballPath = isAbsolute(packedTarball) ? packedTarball : join(tempDir, packedTarball);
40
+ await pipeline(
41
+ createReadStream(tarballPath),
42
+ gunzipMaybe(),
43
+ extract(tempDir)
44
+ );
45
+ const packedManifestRaw = await readFile(
46
+ join(tempDir, "package", "package.json"),
47
+ "utf8"
39
48
  );
40
49
  return JSON.parse(packedManifestRaw);
41
50
  } finally {
@@ -81,4 +90,4 @@ async function replaceWorkspaceProtocolVersions({
81
90
  );
82
91
  }
83
92
 
84
- export { replaceWorkspaceProtocolVersions };
93
+ export { WINDOWS_SHELL_OPTS, replaceWorkspaceProtocolVersions };
@@ -1676,5 +1676,5 @@
1676
1676
  ]
1677
1677
  }
1678
1678
  },
1679
- "version": "11.1.10"
1679
+ "version": "11.1.12"
1680
1680
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "@shopify:registry": "https://registry.npmjs.org"
6
6
  },
7
- "version": "11.1.10",
7
+ "version": "11.1.12",
8
8
  "license": "MIT",
9
9
  "type": "module",
10
10
  "repository": {
@@ -58,8 +58,8 @@
58
58
  "@react-router/dev": "7.12.0",
59
59
  "graphql-config": "^5.0.3",
60
60
  "vite": "^5.1.0 || ^6.2.0",
61
- "@shopify/hydrogen-codegen": "0.3.3",
62
- "@shopify/mini-oxygen": "4.0.1"
61
+ "@shopify/mini-oxygen": "4.0.2",
62
+ "@shopify/hydrogen-codegen": "0.3.3"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "@graphql-codegen/cli": {