@akinon/pz-theme 2.0.110-rc.0 → 2.0.110

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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * ZERO-5084 — the editor canvas and the storefront must pick the SAME answer
3
+ * for a collection-backed section.
4
+ *
5
+ * The server loader resolves a section's live rows in one order (the
6
+ * collection's own widget when the source carries its slug, then the
7
+ * section's own widget, taken only when it carries products). The canvas
8
+ * fetches them from the browser when the document holds none for the current
9
+ * binding. If the two orders drift, the canvas shows one set of rows and the
10
+ * site another, which is the bug this ticket exists to remove.
11
+ */
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+
15
+ jest.mock('server-only', () => ({}), { virtual: true });
16
+
17
+ const getWidgetData = jest.fn();
18
+ const getWidgetSchemaData = jest.fn();
19
+ const getCollectionWidgetData = jest.fn();
20
+
21
+ jest.mock('@akinon/next/data/server', () => ({
22
+ getWidgetData: (...args: unknown[]) => getWidgetData(...args),
23
+ getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
24
+ getCollectionWidgetData: (...args: unknown[]) =>
25
+ getCollectionWidgetData(...args),
26
+ getPreviewSnapshot: async () => null
27
+ }));
28
+
29
+ jest.mock(
30
+ '@akinon/next/utils/app-fetch',
31
+ () => ({ __esModule: true, default: jest.fn() }),
32
+ { virtual: true }
33
+ );
34
+
35
+ jest.mock(
36
+ '@akinon/next/utils/server-variables',
37
+ () => ({ ServerVariables: { locale: 'tr', currency: 'try' } }),
38
+ { virtual: true }
39
+ );
40
+
41
+ import { loadPlaceholderSections } from '../load-placeholder-sections';
42
+ import { fetchDesignerLiveRows, liveRowsPaths } from '../utils/live-rows';
43
+
44
+ const SLOT = 'homepage';
45
+ const SECTION = 'rmd8i';
46
+ const SOURCE_ID = 'ds-1';
47
+ const COLLECTION_SLUG = 'summer-picks';
48
+
49
+ interface Answers {
50
+ /** `/widgets/<collection slug>/` */
51
+ slugWidget: unknown | null;
52
+ /** `/product_collection_widgets/<collection slug>/` */
53
+ collectionWidget: unknown | null;
54
+ /** `/widgets/<section id>/` */
55
+ sectionWidget: unknown | null;
56
+ }
57
+
58
+ const collection = (slug?: string) => ({
59
+ pk: 7,
60
+ productLimit: 6,
61
+ products: [{ pk: 1, name: 'Snapshot' }],
62
+ ...(slug ? { slug } : {})
63
+ });
64
+
65
+ /** The rows the server loader writes into `collection.data`, or null. */
66
+ const serverAnswer = async (slug: string | undefined, answers: Answers) => {
67
+ const dataSources = [
68
+ {
69
+ id: SOURCE_ID,
70
+ name: 'Picks',
71
+ type: 'collection',
72
+ details: { collection: collection(slug) }
73
+ }
74
+ ];
75
+
76
+ getWidgetData.mockImplementation(
77
+ async ({ slug: asked }: { slug: string }) => {
78
+ if (asked === SLOT) {
79
+ return {
80
+ slug: SLOT,
81
+ attributes: {
82
+ theme_editor_placeholder: {
83
+ value: JSON.stringify([{ section_slug: SECTION, order: 0 }])
84
+ }
85
+ }
86
+ };
87
+ }
88
+ if (asked === 'theme-config') {
89
+ return {
90
+ slug: 'theme-config',
91
+ attributes: {
92
+ theme_dataSources: { value: JSON.stringify(dataSources) }
93
+ }
94
+ };
95
+ }
96
+ if (asked === SECTION) return answers.sectionWidget;
97
+ if (asked === COLLECTION_SLUG) return answers.slugWidget;
98
+ return null;
99
+ }
100
+ );
101
+ getCollectionWidgetData.mockImplementation(
102
+ async ({ slug: asked }: { slug: string }) =>
103
+ asked === COLLECTION_SLUG ? answers.collectionWidget : null
104
+ );
105
+ getWidgetSchemaData.mockResolvedValue({
106
+ schema: {
107
+ [SECTION]: {
108
+ metadata: { name: 'Strip', dataSourceId: SOURCE_ID, blocks: [] }
109
+ }
110
+ }
111
+ });
112
+
113
+ const loaded = await loadPlaceholderSections(SLOT);
114
+ return loaded?.sections[0]?.dataSource?.details?.collection?.data ?? null;
115
+ };
116
+
117
+ /** The rows the canvas fetches from the browser, or null. */
118
+ const canvasAnswer = async (slug: string | undefined, answers: Answers) => {
119
+ const byPath: Record<string, unknown | null> = {
120
+ [`/widgets/${COLLECTION_SLUG}/`]: answers.slugWidget,
121
+ [`/product_collection_widgets/${COLLECTION_SLUG}/`]:
122
+ answers.collectionWidget,
123
+ [`/widgets/${SECTION}/`]: answers.sectionWidget
124
+ };
125
+
126
+ (global as unknown as { fetch: unknown }).fetch = jest.fn(
127
+ async (url: string) => {
128
+ // The shared @akinon/next/utils mock hands the path back unchanged.
129
+ const answer = byPath[url.split('?')[0]] ?? null;
130
+ return answer === null
131
+ ? {
132
+ ok: false,
133
+ status: 404,
134
+ json: async () => ({ detail: 'Not found.' })
135
+ }
136
+ : { ok: true, status: 200, json: async () => answer };
137
+ }
138
+ );
139
+
140
+ return fetchDesignerLiveRows(SECTION, collection(slug));
141
+ };
142
+
143
+ const SLUG_ROWS = {
144
+ products: [{ pk: 10, name: 'From the collection widget' }]
145
+ };
146
+ const COLLECTION_ROWS = {
147
+ products: [{ pk: 20, name: 'From product_collection_widgets' }]
148
+ };
149
+ const SECTION_ROWS = {
150
+ slug: SECTION,
151
+ attributes: {},
152
+ products: [{ pk: 30, name: 'From the section widget' }]
153
+ };
154
+ const SECTION_WITHOUT_ROWS = { slug: SECTION, attributes: {}, products: [] };
155
+
156
+ const CASES: Array<[string, string | undefined, Answers]> = [
157
+ [
158
+ 'slug: the collection widget wins',
159
+ COLLECTION_SLUG,
160
+ {
161
+ slugWidget: SLUG_ROWS,
162
+ collectionWidget: COLLECTION_ROWS,
163
+ sectionWidget: SECTION_ROWS
164
+ }
165
+ ],
166
+ [
167
+ 'slug: product_collection_widgets when the widget has none',
168
+ COLLECTION_SLUG,
169
+ {
170
+ slugWidget: null,
171
+ collectionWidget: COLLECTION_ROWS,
172
+ sectionWidget: SECTION_ROWS
173
+ }
174
+ ],
175
+ [
176
+ 'slug: the section widget when neither collection endpoint answers',
177
+ COLLECTION_SLUG,
178
+ { slugWidget: null, collectionWidget: null, sectionWidget: SECTION_ROWS }
179
+ ],
180
+ [
181
+ 'slug: nothing when the section widget carries no products either',
182
+ COLLECTION_SLUG,
183
+ {
184
+ slugWidget: null,
185
+ collectionWidget: null,
186
+ sectionWidget: SECTION_WITHOUT_ROWS
187
+ }
188
+ ],
189
+ [
190
+ 'slug: a collection-widget answer is taken as it is',
191
+ COLLECTION_SLUG,
192
+ {
193
+ slugWidget: { slug: COLLECTION_SLUG, attributes: {} },
194
+ collectionWidget: null,
195
+ sectionWidget: SECTION_ROWS
196
+ }
197
+ ],
198
+ [
199
+ 'no slug: the section widget',
200
+ undefined,
201
+ {
202
+ slugWidget: SLUG_ROWS,
203
+ collectionWidget: null,
204
+ sectionWidget: SECTION_ROWS
205
+ }
206
+ ],
207
+ [
208
+ 'no slug: nothing for an empty product list',
209
+ undefined,
210
+ {
211
+ slugWidget: null,
212
+ collectionWidget: null,
213
+ sectionWidget: SECTION_WITHOUT_ROWS
214
+ }
215
+ ],
216
+ [
217
+ 'no slug: nothing when the section widget does not answer',
218
+ undefined,
219
+ { slugWidget: null, collectionWidget: null, sectionWidget: null }
220
+ ]
221
+ ];
222
+
223
+ beforeEach(() => {
224
+ getWidgetData.mockReset();
225
+ getWidgetSchemaData.mockReset();
226
+ getCollectionWidgetData.mockReset();
227
+ });
228
+
229
+ describe('the canvas asks in the loader order', () => {
230
+ it('lists the endpoints in the loader order', () => {
231
+ expect(liveRowsPaths(SECTION, collection(COLLECTION_SLUG))).toEqual([
232
+ { path: `/widgets/${COLLECTION_SLUG}/`, requireProducts: false },
233
+ {
234
+ path: `/product_collection_widgets/${COLLECTION_SLUG}/`,
235
+ requireProducts: false
236
+ },
237
+ { path: `/widgets/${SECTION}/`, requireProducts: true }
238
+ ]);
239
+ expect(liveRowsPaths(SECTION, collection())).toEqual([
240
+ { path: `/widgets/${SECTION}/`, requireProducts: true }
241
+ ]);
242
+ });
243
+
244
+ it.each(CASES)('%s', async (_name, slug, answers) => {
245
+ const server = await serverAnswer(slug, answers);
246
+ const canvas = await canvasAnswer(slug, answers);
247
+
248
+ expect(canvas).toEqual(server);
249
+ });
250
+ });
251
+
252
+ /**
253
+ * Every renderer that reads a collection's rows used to rank the snapshot
254
+ * first whenever it was framed by the editor. The canvas now gets live rows
255
+ * only when the wrapper put them there for a saved pick, so the readers rank
256
+ * them first everywhere. This pins all six against a branch coming back.
257
+ */
258
+ describe('no reader ranks the snapshot above the live rows', () => {
259
+ const read = (relative: string) =>
260
+ fs
261
+ .readFileSync(path.join(__dirname, '..', relative), 'utf8')
262
+ .replace(/\s+/g, ' ');
263
+
264
+ const READERS: Array<[string, string]> = [
265
+ [
266
+ 'theme-section.tsx',
267
+ 'effectiveSection.dataSource.details.collection?.data || effectiveSection.dataSource.details.collection?.products'
268
+ ],
269
+ [
270
+ 'utils/iterator-utils.ts',
271
+ 'const candidates = [collection?.data, collection?.products];'
272
+ ],
273
+ [
274
+ 'sections/posts-slider-section.tsx',
275
+ 'details.collection?.data?.products, details.collection?.data, details.collection?.products,'
276
+ ],
277
+ [
278
+ 'sections/featured-product-spotlight-section.tsx',
279
+ 'collectionDetails?.data || collectionDetails?.products'
280
+ ],
281
+ [
282
+ 'sections/installment-options-section.tsx',
283
+ 'collectionDetails?.data || collectionDetails?.products'
284
+ ],
285
+ [
286
+ 'sections/pre-order-launch-banner-section.tsx',
287
+ 'collectionDetails?.data || collectionDetails?.products'
288
+ ]
289
+ ];
290
+
291
+ it.each(READERS)('%s', (file, liveFirst) => {
292
+ const source = read(file);
293
+
294
+ expect(source).toContain(liveFirst);
295
+ expect(source).not.toMatch(/collection\w*\?\.products \|\| \w+/);
296
+ expect(source).not.toMatch(/window\.parent !== window/);
297
+ });
298
+ });
@@ -147,8 +147,8 @@ describe('collection-backed sections on the server', () => {
147
147
  sectionWidget: { products: LIVE_ROWS }
148
148
  });
149
149
 
150
- // The designer reads `products` first, so the editor keeps showing the
151
- // collection exactly as it was picked.
150
+ // The editor canvas falls back to `products` while a pick is unsaved
151
+ // (ZERO-5084), so the snapshot has to survive next to the live rows.
152
152
  expect(section?.dataSource.details.collection.products).toEqual(
153
153
  SNAPSHOT_ROWS
154
154
  );
@@ -279,6 +279,17 @@ import fails the brand's build with "cannot be imported from a Client Component
279
279
  module". `@akinon/pz-theme/src/custom` does not re-export it either; the deep
280
280
  module is the one route.
281
281
 
282
+ **A section that draws its collection's rows itself** reads them off its node,
283
+ the way the built-in renderers do: `dataSource.details.collection.data` (the
284
+ section widget's live answer, what the storefront shows) first, then
285
+ `dataSource.details.collection.products` (the snapshot taken when the merchant
286
+ picked the collection). On the editor canvas `data` is only present while the
287
+ editor reports the pick as saved, and the node then carries
288
+ `dataSourceSaved: true`. Without that flag the pick is not published yet, the
289
+ section's widget still answers for the previous collection, and the snapshot is
290
+ the right thing to draw. A section that fetches its own widget on the canvas
291
+ should do so only when `dataSourceSaved` is `true`.
292
+
282
293
  ## Parts must live on YOUR markup
283
294
 
284
295
  Part rules are generated with an ownership guard that excludes anything inside a
@@ -13,6 +13,9 @@ import {
13
13
  PreviewSnapshot
14
14
  } from './chrome/preview-snapshot-source';
15
15
  import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
16
+ // One acceptance rule for this loader and the designer canvas's client fetch
17
+ // (utils/live-rows.ts), so the two cannot take different answers.
18
+ import { carriesProducts } from './utils/live-rows';
16
19
 
17
20
  const { getWidgetData } = serverData;
18
21
 
@@ -74,18 +77,6 @@ const withCollectionData = (dataSource: any, collectionData: unknown): any => ({
74
77
  }
75
78
  });
76
79
 
77
- /**
78
- * A widget answer counts as live rows only when it actually carries a
79
- * non-empty `products` array: a section with no data source attached answers
80
- * without the key at all, and an empty list must never replace the editor's
81
- * snapshot with nothing to render.
82
- */
83
- const carriesProducts = (widgetData: unknown): boolean =>
84
- !!widgetData &&
85
- typeof widgetData === 'object' &&
86
- Array.isArray((widgetData as { products?: unknown }).products) &&
87
- (widgetData as { products: unknown[] }).products.length > 0;
88
-
89
80
  /**
90
81
  * Resolves a section's data source reference into the object the renderer
91
82
  * expects, fetching collection data for collection-backed sources. Shared by
@@ -164,8 +155,14 @@ async function resolveSectionDataSource(
164
155
  * path they take.
165
156
  *
166
157
  * Only ever fills an ABSENT `data` — a source that already resolved keeps
167
- * what it resolved to, and the editor reads `products` first either way, so
168
- * the designer still sees the collection exactly as it was picked.
158
+ * what it resolved to.
159
+ *
160
+ * The editor canvas renders this same document. Its wrapper keeps this
161
+ * `data` only while the editor reports the section's pick as saved
162
+ * (`dataSourceSaved`, ZERO-5084): the widget is then bound to the collection
163
+ * the editor shows, so these rows are exactly the site's. While a pick is
164
+ * unsaved the wrapper drops them and the canvas shows the picked snapshot,
165
+ * `products`. See utils/live-rows.ts.
169
166
  */
170
167
  if (
171
168
  dataSource.details.collection &&
@@ -146,17 +146,15 @@ const formatPriceWithCurrency = (
146
146
  };
147
147
 
148
148
  const getCollectionProducts = (
149
- section: Section,
150
- isDesigner: boolean
149
+ section: Section
151
150
  ): Record<string, unknown>[] => {
152
151
  const collectionDetails = section.dataSource?.details?.collection;
153
152
  const staticData = section.dataSource?.details?.static?.data;
154
- const isEditorMode =
155
- typeof window !== 'undefined' && isDesigner && window.parent !== window;
156
-
157
- const collectionPayload = isEditorMode
158
- ? collectionDetails?.products || collectionDetails?.data
159
- : collectionDetails?.data || collectionDetails?.products;
153
+ // Live rows first in every mode. On the editor canvas the wrapper leaves
154
+ // `data` in place only for a saved pick, so an unsaved pick still shows its
155
+ // snapshot (utils/live-rows.ts).
156
+ const collectionPayload =
157
+ collectionDetails?.data || collectionDetails?.products;
160
158
 
161
159
  if (Array.isArray(collectionPayload)) {
162
160
  return collectionPayload as Record<string, unknown>[];
@@ -260,8 +258,8 @@ export default function FeaturedProductSpotlightSection({
260
258
  ]);
261
259
 
262
260
  const products = useMemo(
263
- () => getCollectionProducts(section, isDesigner),
264
- [section, isDesigner]
261
+ () => getCollectionProducts(section),
262
+ [section]
265
263
  );
266
264
  const fallbackProductPk = parsePositiveInt(
267
265
  getResponsiveValue(
@@ -259,17 +259,15 @@ const formatPriceWithCurrency = (
259
259
  };
260
260
 
261
261
  const getCollectionProducts = (
262
- section: Section,
263
- isDesigner: boolean
262
+ section: Section
264
263
  ): ProductLike[] => {
265
264
  const collectionDetails = section.dataSource?.details?.collection;
266
265
  const staticData = section.dataSource?.details?.static?.data;
267
- const isEditorMode =
268
- typeof window !== 'undefined' && isDesigner && window.parent !== window;
269
-
270
- const collectionPayload = isEditorMode
271
- ? collectionDetails?.products || collectionDetails?.data
272
- : collectionDetails?.data || collectionDetails?.products;
266
+ // Live rows first in every mode. On the editor canvas the wrapper leaves
267
+ // `data` in place only for a saved pick, so an unsaved pick still shows its
268
+ // snapshot (utils/live-rows.ts).
269
+ const collectionPayload =
270
+ collectionDetails?.data || collectionDetails?.products;
273
271
 
274
272
  if (Array.isArray(collectionPayload)) {
275
273
  return collectionPayload as ProductLike[];
@@ -563,8 +561,8 @@ export default function InstallmentOptionsSection({
563
561
  );
564
562
 
565
563
  const sectionProducts = useMemo(
566
- () => getCollectionProducts(section, isDesigner),
567
- [section, isDesigner]
564
+ () => getCollectionProducts(section),
565
+ [section]
568
566
  );
569
567
  const collectionProduct =
570
568
  (sectionProducts[0] as ProductLike | undefined) || null;
@@ -221,8 +221,7 @@ const replaceBlockValues = (
221
221
 
222
222
  const getIteratorProducts = (
223
223
  section: any,
224
- iteratorBlock: any,
225
- isDesigner: boolean
224
+ iteratorBlock: any
226
225
  ): Record<string, unknown>[] => {
227
226
  if (!section?.dataSource?.details) return [];
228
227
 
@@ -231,23 +230,17 @@ const getIteratorProducts = (
231
230
  iteratorBlock.properties?.iteratorDataPath;
232
231
 
233
232
  const details = section.dataSource.details;
234
- const inEditor =
235
- isDesigner && typeof window !== 'undefined' && window.parent !== window;
236
-
237
- const candidates: unknown[] = inEditor
238
- ? [
239
- details.collection?.products,
240
- details.static?.data,
241
- details.collection?.data,
242
- details
243
- ]
244
- : [
245
- details.collection?.data?.products,
246
- details.collection?.data,
247
- details.collection?.products,
248
- details.static?.data,
249
- details
250
- ];
233
+
234
+ // Live rows first in every mode. On the editor canvas the wrapper leaves
235
+ // `data` in place only for a saved pick, so an unsaved pick still shows its
236
+ // snapshot (utils/live-rows.ts).
237
+ const candidates: unknown[] = [
238
+ details.collection?.data?.products,
239
+ details.collection?.data,
240
+ details.collection?.products,
241
+ details.static?.data,
242
+ details
243
+ ];
251
244
 
252
245
  for (const candidate of candidates) {
253
246
  if (Array.isArray(candidate)) {
@@ -345,7 +338,7 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
345
338
  }
346
339
 
347
340
  const template = block.blocks[0];
348
- const products = getIteratorProducts(section, block, isDesigner);
341
+ const products = getIteratorProducts(section, block);
349
342
  const fallbackCount = clampNumber(
350
343
  parseNumber(block.properties?.iteratorCount, 1),
351
344
  1,
@@ -237,17 +237,15 @@ const formatLaunchDate = (
237
237
  };
238
238
 
239
239
  const getCollectionProducts = (
240
- section: Section,
241
- isDesigner: boolean
240
+ section: Section
242
241
  ): Record<string, unknown>[] => {
243
242
  const collectionDetails = section.dataSource?.details?.collection;
244
243
  const staticData = section.dataSource?.details?.static?.data;
245
- const isEditorMode =
246
- typeof window !== 'undefined' && isDesigner && window.parent !== window;
247
-
248
- const collectionPayload = isEditorMode
249
- ? collectionDetails?.products || collectionDetails?.data
250
- : collectionDetails?.data || collectionDetails?.products;
244
+ // Live rows first in every mode. On the editor canvas the wrapper leaves
245
+ // `data` in place only for a saved pick, so an unsaved pick still shows its
246
+ // snapshot (utils/live-rows.ts).
247
+ const collectionPayload =
248
+ collectionDetails?.data || collectionDetails?.products;
251
249
 
252
250
  if (Array.isArray(collectionPayload)) {
253
251
  return collectionPayload as Record<string, unknown>[];
@@ -363,8 +361,8 @@ export default function PreOrderLaunchBannerSection({
363
361
  ]);
364
362
 
365
363
  const products = useMemo(
366
- () => getCollectionProducts(section, isDesigner),
367
- [section, isDesigner]
364
+ () => getCollectionProducts(section),
365
+ [section]
368
366
  );
369
367
  const fallbackProductPk = parsePositiveInt(
370
368
  getResponsiveValue(
@@ -27,6 +27,14 @@ import {
27
27
  HOVER_REVEAL_CSS
28
28
  } from './utils/property-switches';
29
29
  import { isPublishWindowVisible } from './utils/publish-window';
30
+ import {
31
+ collectionBindingKey,
32
+ fetchDesignerLiveRows,
33
+ hangDesignerDataSource,
34
+ liveRowsEntryKey,
35
+ LiveRowsStore,
36
+ seedLiveRows
37
+ } from './utils/live-rows';
30
38
  import { buildClientRequestUrl } from '@akinon/next/utils';
31
39
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
32
40
  import { parseEditorMessage, postToEditor } from './editor-bridge';
@@ -417,6 +425,24 @@ export default function ThemePlaceholderWrapper({
417
425
  true;
418
426
  }, []);
419
427
  const [dataSources, setDataSources] = useState<any[]>(initialDataSources);
428
+ // Live rows for collection-backed sections on the designer canvas, keyed by
429
+ // section and binding (utils/live-rows.ts). Seeded from the rows the server
430
+ // wrote into this document, so a saved section shows exactly what the
431
+ // storefront rendered without a request.
432
+ const [liveRowsSeed] = useState(() => seedLiveRows(initialSections));
433
+ const liveRowsRef = useRef<LiveRowsStore>(liveRowsSeed);
434
+ const liveRowsAttemptedRef = useRef<Set<string>>(new Set());
435
+ const liveRowsMountedRef = useRef(true);
436
+ const [liveRowsRevision, setLiveRowsRevision] = useState(0);
437
+ // Whether the editor has sent its theme for this placeholder yet. Only its
438
+ // sections say which picks are saved (`dataSourceSaved`).
439
+ const editorThemeHeardRef = useRef(false);
440
+ useEffect(() => {
441
+ liveRowsMountedRef.current = true;
442
+ return () => {
443
+ liveRowsMountedRef.current = false;
444
+ };
445
+ }, []);
420
446
  const [themeSettings, setThemeSettings] = useState<Record<
421
447
  string,
422
448
  unknown
@@ -720,8 +746,12 @@ export default function ThemePlaceholderWrapper({
720
746
  }
721
747
  }, []);
722
748
 
749
+ // Re-hangs the editor's data-source records over the sections, and again
750
+ // whenever live rows arrive (`liveRowsRevision`). Waits for the editor's
751
+ // theme: until it arrives nothing says which picks are unsaved, and the
752
+ // server-rendered rows are the ones the storefront shows.
723
753
  useEffect(() => {
724
- if (!isDesigner) return;
754
+ if (!isDesigner || !editorThemeHeardRef.current) return;
725
755
  if (dataSources && dataSources.length > 0) {
726
756
  setSections((prevSections) => {
727
757
  return prevSections.map((section) => {
@@ -732,14 +762,56 @@ export default function ThemePlaceholderWrapper({
732
762
 
733
763
  return {
734
764
  ...section,
735
- dataSource: dataSource || section.dataSource
765
+ dataSource: hangDesignerDataSource(
766
+ section,
767
+ dataSource || section.dataSource,
768
+ liveRowsRef.current
769
+ )
736
770
  };
737
771
  }
738
772
  return section;
739
773
  });
740
774
  });
741
775
  }
742
- }, [dataSources, isDesigner]);
776
+ }, [dataSources, isDesigner, liveRowsRevision]);
777
+
778
+ // A section whose pick is saved but whose current binding has no rows yet —
779
+ // it was re-pointed and published in this session, the document is a draft
780
+ // preview (whose blob path writes no live rows), or the server could not
781
+ // resolve them — asks the storefront for them once. Until they arrive the
782
+ // picked snapshot stays on the canvas: it is the same collection.
783
+ useEffect(() => {
784
+ if (!isDesigner) return;
785
+
786
+ sections.forEach((section) => {
787
+ if (section.dataSourceSaved !== true || !section.id) return;
788
+
789
+ const bindingKey = collectionBindingKey(section.dataSource);
790
+ if (!bindingKey) return;
791
+
792
+ const entryKey = liveRowsEntryKey(section.id, bindingKey);
793
+ if (
794
+ liveRowsRef.current.has(entryKey) ||
795
+ liveRowsAttemptedRef.current.has(entryKey)
796
+ ) {
797
+ return;
798
+ }
799
+
800
+ liveRowsAttemptedRef.current.add(entryKey);
801
+
802
+ fetchDesignerLiveRows(
803
+ section.id,
804
+ section.dataSource?.details?.collection
805
+ ).then((answer) => {
806
+ if (answer === null || !liveRowsMountedRef.current) return;
807
+
808
+ liveRowsRef.current.set(entryKey, answer);
809
+ setLiveRowsRevision((revision) => revision + 1);
810
+ });
811
+ });
812
+ // `sections` already follows `dataSources`: every change to the records is
813
+ // hung over the sections before this runs.
814
+ }, [sections, isDesigner]);
743
815
 
744
816
  useEffect(() => {
745
817
  if (typeof window === 'undefined') {
@@ -818,6 +890,8 @@ export default function ThemePlaceholderWrapper({
818
890
  }
819
891
 
820
892
  if (placeholder.sections) {
893
+ editorThemeHeardRef.current = true;
894
+
821
895
  // In editor mode: Complete override with theme editor's data
822
896
  // This allows unsaved sections to render with editor's product data
823
897
  setSections(() => {
@@ -842,7 +916,13 @@ export default function ThemePlaceholderWrapper({
842
916
  properties: incomingSection.properties
843
917
  ? { ...incomingSection.properties }
844
918
  : {},
845
- dataSource: dataSource || null
919
+ // Live rows only for a pick the editor reports as
920
+ // saved; otherwise the picked snapshot.
921
+ dataSource: hangDesignerDataSource(
922
+ incomingSection,
923
+ dataSource || null,
924
+ liveRowsRef.current
925
+ )
846
926
  };
847
927
  });
848
928
  });