6.4 KiB
Nested Layout Execution — Race Conditions & Solutions
Problem
Widgets stored inside nested layouts (e.g. TabsWidget tabs) are not displayed in edit mode, despite working in view mode. The root cause is a race condition between layout hydration and the SYNC-BACK effect.
Architecture Overview
UserPage → LayoutProvider (single instance)
└── GenericCanvas (main page)
└── GenericCanvasEdit
└── LayoutContainerEdit
└── TabsWidget (widget with nested layouts)
└── GenericCanvas (per-tab sub-layout)
└── GenericCanvasEdit (child)
Key files
| File | Role |
|---|---|
| LayoutContext.tsx | Shared loadedPages state, hydratePageLayout, loadPageLayout |
| GenericCanvas.tsx | Suspense wrapper — lazy-loads Edit, falls back to View |
| GenericCanvasEdit.tsx | Edit mode canvas — hydration useEffect (lines 73-100) |
| GenericCanvasView.tsx | View mode canvas — also hydrates from initialLayout |
| TabsWidget.tsx | Nested layout host — SYNC-BACK effect (lines 100-139) |
| LayoutManager.ts | getPageLayout — creates empty defaults, loadRootData prefix matching |
| LayoutContainerEdit.tsx | Renders widgets with {...widget.props} spread (line 776) |
Race Condition Sequence
1. Main page hydrates → TabsWidget mounts with stored `tabs[].layoutData`
2. TabsWidget renders <GenericCanvas initialLayout={tab.layoutData}> per tab
3. GenericCanvasEdit mounts — layout=undefined, initialLayout=stored data
4. BEFORE useEffect hydration runs:
├── Suspense fallback (GenericCanvasView) may call loadPageLayout()
│ └── loadRootData("tab-layout-1") → prefix mismatch → empty default
└── OR GenericCanvasEdit itself calls loadPageLayout() on wrong branch
5. Empty layout enters loadedPages with fresh Date.now() timestamp
6. SYNC-BACK fires → empty layout is "newer" → overwrites stored layoutData
7. Stored widgets are permanently lost for this session
Prefix mismatch detail
LayoutManager.ts loadRootData (line 153-154):
const isPage = pageId.startsWith('page-');
const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-');
tab-layout-* starts with tab- (not tabs-), so neither branch matches → returns empty default.
Current Fix (band-aid)
TabsWidget.tsx lines 108-116:
SYNC-BACK compares live vs stored widget counts. If live=0 and stored>0, skips the sync and re-hydrates from stored data. This is a heuristic guard — it doesn't generalize to all edge cases (e.g. legitimately empty nested layouts that later get widgets added).
Proposed Solutions
Solution 1: hydratedIds set in LayoutContext ⭐ recommended
Track which layout IDs have been authoritatively populated vs created as empty defaults.
// Mutable set — no reactivity needed, checked synchronously during SYNC-BACK
const [hydratedIds] = useState(() => new Set<string>());
const hydratePageLayout = useCallback((pageId: string, layout: PageLayout) => {
hydratedIds.add(pageId); // mark as authoritatively hydrated
setLoadedPages(prev => new Map(prev).set(pageId, layout));
setIsLoading(false);
}, []);
Expose hydratedIds (or an isHydrated(id) helper) via context.
In TabsWidget.tsx (and any future nested layout widget):
const layout = loadedPages.get(t.layoutId);
if (layout && !isHydrated(t.layoutId) && t.layoutData) {
// Not yet authoritatively populated — skip SYNC-BACK
return t;
}
State machine per layout ID:
UNKNOWN ──► HYDRATING ──► READY
│ ▲
└── (no initialLayout) ──┘ (loadPageLayout = also READY)
Edge cases handled:
| Case | initialLayout |
hydratedIds |
SYNC-BACK |
|---|---|---|---|
| Stored tab with widgets | ✅ present | set on hydrate | trusts after hydrate |
| New empty tab (user just created) | ❌ undefined | set on loadPageLayout | trusts empty layout ✅ |
| Tab inside tab (deep nesting) | ✅ per level | each level independent | each SYNC-BACK checks own children |
| Implicit container auto-creation | n/a (mutation) | doesn't change flag | no effect on SYNC-BACK |
Solution 2: Gate loadPageLayout when initialLayout exists
In GenericCanvasEdit.tsx line 96-99:
-if (!layout) {
+if (!layout && !initialLayout) {
loadPageLayout(pageId, pageName);
}
Same change in GenericCanvasView.tsx line 38-40.
This prevents the empty default from ever being created when initialLayout is provided. The canvas will stay in loading state until the hydration effect runs (next tick).
Pros: 2-line fix, eliminates the root cause.
Cons: Doesn't protect against future patterns where a nested layout might lose its initialLayout prop during React reconciliation.
Solution 3: Fix the prefix mismatch
In LayoutManager.ts line 154:
-const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-');
+const isLayout = pageId.startsWith('layout-') || pageId.startsWith('tabs-') || pageId.startsWith('tab-');
Pros: 1-line fix, prevents DB miss for tab sub-layouts. Cons: Only addresses one symptom — any nested layout with an unrecognized prefix would still hit the same problem.
Recommended Approach
Apply all three in order of priority:
- Solution 2 (gate
loadPageLayout) — eliminates the source of empty layouts, 2 lines - Solution 1 (
hydratedIds) — semantic guard for SYNC-BACK, generalizes to any nesting depth - Solution 3 (prefix fix) — defense in depth, prevents DB misses
Then remove the current widget-counting heuristic from TabsWidget, since hydratedIds makes it redundant.