Skip to content

Commit 34dca7a

Browse files
authored
Fix new schema areas in existing documents (Astro) Part II (#5440)
* Prevent data corruption when stubbing areas for Astro * Fix false positive orphan area warnings * Guard against corrupt area items
1 parent 1fa59e4 commit 34dca7a

5 files changed

Lines changed: 394 additions & 77 deletions

File tree

‎packages/apostrophe-astro/components/AposArea.astro‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,22 @@ const {
1313
1414
let attributes = {};
1515
16-
// The backend sets `field` only on areas still in the schema; a missing or
17-
// orphaned area lacks it and renders nothing.
18-
const isArea = Boolean(area?.field);
16+
// Enough structure to render as an area at all (hardens against malformed
17+
// data — never crash the render).
18+
const renderable = area?.metaType === "area" && Array.isArray(area?.items);
1919
20-
const widgets: Record<string, any>[] = area?.items || [];
20+
const isOrphan = Boolean(area?._isOrphan);
21+
const isArea = renderable && !isOrphan;
2122
22-
const isEdit = isArea && area?._edit && Astro.url.searchParams.get("aposEdit");
23+
const hasField = Boolean(area?.field);
24+
25+
// Defensive: a corrupt item (null/typeless) must never crash the render.
26+
const widgets: Record<string, any>[] = (area?.items || []).filter(
27+
(item: any) => item && item.type,
28+
);
29+
30+
const isEdit =
31+
hasField && area?._edit && Astro.url.searchParams.get("aposEdit");
2332
const forceWrapper = aposAttributes || aposStyle || aposClassName;
2433
2534
const WidgetComponent = widgetComponent ?? AposWidget;
@@ -84,10 +93,12 @@ function getWidgetOptions(options: any = {}) {
8493
})}
8594
</Wrapper>
8695
) : (
87-
// Orphaned area. Dev-only diagnostic: `import.meta.env.DEV` is replaced
88-
// with `false` in production, so this branch is dead-code eliminated.
96+
// Genuine orphan only. Dev-only diagnostic: `import.meta.env.DEV` is
97+
// replaced with `false` in production, so this branch is dead-code
98+
// eliminated. Un-annotated (e.g. REST-delivered) areas never reach here —
99+
// they render above.
89100
(import.meta as any).env.DEV &&
90-
area?.metaType === "area" && (
101+
isOrphan && (
91102
<div
92103
data-apos-area-error
93104
role="alert"

‎packages/apostrophe/modules/@apostrophecms/area/index.js‎

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ module.exports = {
140140
// so this logic is reproduced partially
141141
self.apos.doc.walk(area, (o, k, v) => {
142142
if (v && v.metaType === 'area') {
143-
const manager = self.apos.util.getManagerOf(o);
143+
const manager = self.apos.util.getManagerOf(o, { log: false });
144144
if (!manager) {
145145
self.apos.util.warnDevOnce(
146146
'noManagerForDocInExternalFront',
@@ -285,37 +285,95 @@ module.exports = {
285285
self.missingWidgetTypes[name] = true;
286286
}
287287
},
288-
// Build an empty area and attach it to `parent[name]`. When `parent`
289-
// is doc-backed (has `_docId`, or is itself a doc) and `areaDotPath`
290-
// is provided, also persist the area at that dot-path. The write is
291-
// idempotent via `$eq: null`, so concurrent renders won't clobber each
292-
// other. Returns the area.
288+
// Build an empty area and attach it to `parent[name]`. When the area's
289+
// location can be resolved inside the *persisted* document, also stub it
290+
// into the database so the backend recognizes it for later edits.
291+
// Returns the area.
293292
//
294-
// Used by the `{% area %}` tag and the external front annotator as a
293+
// Options:
294+
// - `throwIfNotFound` (default `false`): when `parent` is doc-backed but
295+
// the document or the container cannot be located in the database,
296+
// throw a `notfound` error instead of returning an in-memory-only
297+
// stub. The `{% area %}` tag opts in to preserve its historical
298+
// behavior; the external front annotator leaves it off so a render is
299+
// never brought down by such a case.
300+
//
301+
// Used by the `{% area %}` tag and the external front annotator as the
295302
// single source of truth for stubbing schema areas that have no value
296303
// yet.
297-
async addMissingArea(parent, name, areaDotPath) {
304+
async addMissingArea(parent, name, { throwIfNotFound = false } = {}) {
298305
const area = {
299306
metaType: 'area',
300307
_id: self.apos.util.generateId(),
301308
items: []
302309
};
303310
parent[name] = area;
311+
304312
const docId = parent._docId ??
305313
(parent.metaType === 'doc' ? parent._id : null);
306-
if (docId && areaDotPath) {
307-
await self.apos.doc.db.updateOne(
308-
{
309-
_id: docId,
310-
[areaDotPath]: { $eq: null }
311-
},
312-
{
313-
$set: { [areaDotPath]: self.apos.util.clonePermanent(area) }
314-
}
315-
);
314+
const areaDotPath = await self.resolvePersistedAreaDotPath(parent, name);
315+
if (!areaDotPath) {
316+
if (throwIfNotFound && docId) {
317+
throw self.apos.error('notfound');
318+
}
319+
return area;
320+
}
321+
322+
const result = await self.apos.doc.db.updateOne(
323+
{
324+
_id: docId,
325+
// Idempotent and race-safe: only write when still absent.
326+
[areaDotPath]: { $eq: null }
327+
},
328+
{
329+
$set: { [areaDotPath]: self.apos.util.clonePermanent(area) }
330+
}
331+
);
332+
if (result.modifiedCount === 0) {
333+
// Another request stubbed it first (or it already existed): adopt
334+
// the persisted `_id` so we render the same area.
335+
const refreshed = await self.apos.doc.db.findOne({ _id: docId });
336+
const persisted = refreshed && self.apos.util.get(refreshed, areaDotPath);
337+
if (persisted?._id) {
338+
area._id = persisted._id;
339+
}
316340
}
317341
return area;
318342
},
343+
344+
// Resolve the MongoDB dot-path at which `parent[name]` should be stored,
345+
// computed from the *persisted* document so it always reflects real
346+
// storage (not the in-memory graph with its loaded relationships). The
347+
// `parent` object is located inside the freshly read document by its
348+
// `_id`. Returns the dot-path string, or `null` when the area cannot be
349+
// safely persisted (no doc id, doc not in the database, or `parent` is
350+
// not part of the persisted document, e.g. loaded relationship data).
351+
async resolvePersistedAreaDotPath(parent, name) {
352+
const docId = parent._docId ??
353+
(parent.metaType === 'doc' ? parent._id : null);
354+
if (!docId) {
355+
return null;
356+
}
357+
const mainDoc = await self.apos.doc.db.findOne({ _id: docId });
358+
if (!mainDoc) {
359+
return null;
360+
}
361+
if (parent._id === docId) {
362+
return name;
363+
}
364+
if (!parent._id) {
365+
return null;
366+
}
367+
const found = self.apos.util.findNestedObjectAndDotPathById(
368+
mainDoc,
369+
parent._id,
370+
{ ignoreDynamicProperties: true }
371+
);
372+
if (!found) {
373+
return null;
374+
}
375+
return `${found.dotPath}.${name}`;
376+
},
319377
prepForRender(area, context, fieldName) {
320378
const manager = self.apos.util.getManagerOf(context);
321379
const field = manager.schema.find(field => field.name === fieldName);

‎packages/apostrophe/modules/@apostrophecms/area/lib/custom-tags/area.js‎

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -59,34 +59,7 @@ module.exports = function(self) {
5959
}
6060
area = doc[name];
6161
if (!area) {
62-
// Problem: area is in schema but that doesn't guarantee it
63-
// has a value, for instance the field could be new in the schema.
64-
// But we need an area _id. Stub it into the db on the fly
65-
// without race conditions
66-
const docId = doc._docId || ((doc.metaType === 'doc') ? doc._id : null);
67-
let areaDotPath;
68-
if (docId) {
69-
const mainDoc = await self.apos.doc.db.findOne({ _id: docId });
70-
if (!mainDoc) {
71-
throw self.apos.error('notfound');
72-
}
73-
let docDotPath;
74-
try {
75-
docDotPath = (doc._id === docId) ? '' : self.apos.util.findNestedObjectAndDotPathById(mainDoc, doc._id).dotPath;
76-
} catch (e) {
77-
// Race condition: someone removed the area's parent object.
78-
// Unlikely thanks to advisory locking
79-
throw self.apos.error('notfound');
80-
}
81-
areaDotPath = docDotPath ? `${docDotPath}.${name}` : name;
82-
}
83-
area = await self.apos.area.addMissingArea(doc, name, areaDotPath);
84-
if (docId) {
85-
// Race-safety: re-read the persisted _id in case another request
86-
// wrote first (our $eq: null write was a no-op in that case).
87-
const refreshed = await self.apos.doc.db.findOne({ _id: docId });
88-
area._id = self.apos.util.get(refreshed, areaDotPath)._id;
89-
}
62+
area = await self.apos.area.addMissingArea(doc, name, { throwIfNotFound: true });
9063
}
9164
const manager = self.apos.util.getManagerOf(doc);
9265
const field = manager.schema.find(field => field.name === name);

‎packages/apostrophe/modules/@apostrophecms/template/index.js‎

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,19 +1352,18 @@ module.exports = {
13521352
async annotateDocForExternalFront(doc, { scene } = {}) {
13531353
const handled = new WeakSet();
13541354
const missingAreas = [];
1355-
self.apos.doc.walk(doc, (o, k, v, __dotPath) => {
1355+
self.apos.doc.walk(doc, (o, k, v) => {
13561356
if (o._edit === true && !handled.has(o)) {
13571357
handled.add(o);
1358-
// `__dotPath` is the path to `v` (= o[k]); the container `o` lives
1359-
// one segment up — '' for the top-level doc.
1360-
const dot = __dotPath.lastIndexOf('.');
1361-
const containerDotPath = dot === -1 ? '' : __dotPath.substring(0, dot);
13621358
for (const field of self.missingSchemaAreas(o)) {
1363-
missingAreas.push([ o, field, containerDotPath ]);
1359+
missingAreas.push([ o, field ]);
13641360
}
13651361
}
13661362
if (v && v.metaType === 'area') {
1367-
const manager = self.apos.util.getManagerOf(o);
1363+
// A missing manager here is expected (e.g. an area reached on a
1364+
// container without a manager) and handled below, so suppress the
1365+
// low-level per-call log and rely on the once-per-process warning.
1366+
const manager = self.apos.util.getManagerOf(o, { log: false });
13681367
if (!manager) {
13691368
self.apos.util.warnDevOnce(
13701369
'noManagerForDocInExternalFront',
@@ -1374,6 +1373,7 @@ module.exports = {
13741373
}
13751374
const field = manager.schema.find(f => f.name === k);
13761375
if (!field) {
1376+
v._isOrphan = true;
13771377
self.apos.util.warnDevOnce(
13781378
'noSchemaFieldForAreaInExternalFront',
13791379
`Area ${k} has no matching schema field in ${o.metaType} ${o.type || ''}`
@@ -1385,15 +1385,8 @@ module.exports = {
13851385
});
13861386
// Materialize every missing area, after the walk so we never add keys
13871387
// to an object while it is being traversed.
1388-
for (const [ o, field, containerDotPath ] of missingAreas) {
1389-
const areaDotPath = containerDotPath
1390-
? `${containerDotPath}.${field.name}`
1391-
: field.name;
1392-
const area = await self.apos.area.addMissingArea(
1393-
o,
1394-
field.name,
1395-
areaDotPath
1396-
);
1388+
for (const [ o, field ] of missingAreas) {
1389+
const area = await self.apos.area.addMissingArea(o, field.name);
13971390
area._edit = true;
13981391
area._docId = o._docId ?? (o.metaType === 'doc' ? o._id : null);
13991392
self.annotateAreaForExternalFront(field, area, { scene });
@@ -1406,6 +1399,7 @@ module.exports = {
14061399
// at least as an empty array.
14071400

14081401
annotateAreaForExternalFront(field, area, { scene } = {}) {
1402+
area._aposAnnotated = true;
14091403
area.field = field;
14101404
area.options = field.options;
14111405
// Really widget configurations, but the method name is already set in
@@ -1420,23 +1414,32 @@ module.exports = {
14201414
};
14211415
}).filter(choice => !!choice);
14221416

1423-
area.items ||= [];
1417+
// Drop corrupt items (null, or not a widget).
1418+
area.items = (area.items || []).filter((item) => {
1419+
const valid = item && item.metaType === 'widget' && item.type;
1420+
if (!valid) {
1421+
self.apos.util.warnDevOnce(
1422+
'corruptAreaItemInExternalFront',
1423+
`Dropping malformed item in area ${area._id || ''}`
1424+
);
1425+
}
1426+
return valid;
1427+
});
1428+
14241429
for (const item of area.items) {
14251430
// Add _docId if area has one
14261431
if (area._docId) {
14271432
item._docId = area._docId;
14281433
}
14291434

1430-
// Annotate each individual widget with its options
1431-
// Each widget must elect into this by creating an
1432-
// `annotateWidgetForExternalFront() method.
1435+
// Annotate each individual widget with its options. Each widget must
1436+
// elect into this by creating an `annotateWidgetForExternalFront()`
1437+
// method.
14331438
const manager = self.apos.area.getWidgetManager(item.type);
14341439
if (manager) {
1435-
const widgetOptions = manager.annotateWidgetForExternalFront(item, { scene });
1436-
item._options = widgetOptions;
1440+
item._options = manager.annotateWidgetForExternalFront(item, { scene });
14371441
} else {
14381442
self.apos.area.warnMissingWidgetType(item.type);
1439-
throw self.apos.error('invalid', 'Missing widget type');
14401443
}
14411444
}
14421445
},

0 commit comments

Comments
 (0)