Edge cases + gotchas
Surprising behaviours, race conditions, and ordering quirks you'll want to know before they surprise you in production.
1. Auto-seed fires on first read, not on tenant create
The migration installs the help_tutorials table empty. Rows only appear when a logged-in admin visits a tutorial-aware page for the first time. The GetForPage resolver detects the miss and calls SeedDefaults inline before returning.
Consequence: if no admin has logged in since the tenant was provisioned, the table is empty and the /admin/help-tutorials list looks broken. The fix is "just visit an admin page once" — or run POST /admin/help-tutorials/seed against the tenant.
2. localStorage read state vs server-side help_tutorial_reads
The unread dot uses localStorage as the primary source:
isPageRead(pageKey) → reads from localStorage["help-tutorials-read"]
The POST /admin/help-tutorials/{pageKey}/read server-side mark is best-effort for cross-device parity, but the frontend doesn't read from the server on page load. This means:
- Operator marks read on desktop → desktop's localStorage has it → dot gone.
- Same operator on mobile → mobile's localStorage doesn't have it → dot still shows.
- Server-side
help_tutorial_readshas the row (set by the desktop click) but the mobile frontend never queries it.
This is intentional — keeps the unread check from making a DB round-trip on every page nav. The cost is cross-device drift.
3. The page_key resolver strips dynamic segments
/admin/products → admin.products
/admin/products/abc-uuid → admin.products
/admin/products/abc/edit → admin.products.edit
/admin/products/abc/view → admin.products.view
/admin/orders/12345 → admin.orders
UUIDs and numeric segments are dropped UNLESS the next segment is edit or view. If your custom admin page uses different action keywords (e.g. /admin/widgets/{id}/preview), the preview is treated as a name segment and ends up in the key (admin.widgets.preview). Wire your tutorial under the key the resolver actually produces.
4. Disabling the module preserves rows but mutes the icon
Toggling the module OFF:
useModuleEnabled("help-tutorials")returns false on every page.<HelpButton>returnsnull→ no icon, no auto-open.- Backend
/admin/help-tutorials/*returns 403 withmodule_disabled. - The
help_tutorialsandhelp_tutorial_readstables retain their rows.
Re-enabling restores everything. There is no "soft re-prompt" — operators who had unread dots before the disable still have unread dots after re-enable (localStorage and DB are untouched).
5. Save-during-fetch race when editing a tutorial
The editor modal fetches the full tutorial in handleEdit (via helpApi.getForPage). The list page returns lightweight summaries — only when you click the pencil does the full row load. If you click pencil → click pencil on a different row before the first fetch resolves, the form may show stale data. Wait for the modal to render before clicking another row.
6. Language fallback chain
Resolver order:
- Try
(page_key, user_locale). - If miss AND user_locale is not
en, try(page_key, en). - If still miss, return null → panel shows "no content yet".
There is no chained fallback to other locales — only en is the fallback target. Keep an EN row for every page as a defensive layer, even if your admins use another language.
7. Steps and settings_help arrays are filtered server-side
When you save, the frontend strips entries where both relevant fields are empty:
steps: form.steps.filter(s => s.title.trim() || s.content.trim())
settings_help: form.settings_help.filter(s => s.label.trim() || s.description.trim())
Adding an empty step and saving doesn't persist it. To leave a step intentionally blank (placeholder for later), add at least a title.
8. The same tutorial in two languages can drift
Each (page_key, language) is independent. Editing the EN row does NOT propagate to other languages. If you maintain a bilingual platform, you'll need to update both. There's no "link translations" feature in MVP.
9. Read counts in the list view
The list view's read_count column is computed by:
COALESCE((SELECT COUNT(*) FROM help_tutorial_reads htr
WHERE htr.page_key = ht.page_key), 0)
Note: it counts by page_key not by tutorial UUID. If you delete a tutorial and create another for the same page_key, the new row inherits the old read count. To zero it for that key:
DELETE FROM help_tutorial_reads WHERE page_key = 'admin.foo';
10. The Preview button shows current editor state, not DB state
In edit mode, the preview reads from React state (the in-progress edits) — not from the database row. This means you can preview unsaved changes safely. But it also means: if you opened the editor, made no changes, and clicked Preview, you might miss a server-side rendering quirk because you're previewing the FORM not the SAVED row. To be paranoid: save, then open the actual page where the tutorial lives.
11. Seed defaults writes English content under any language
POST /admin/help-tutorials/seed always seeds defaultTutorialsEN(), tagged with the request's language (default en). Seeding for es, for example, stores English text under language = es until you translate those rows. The ON CONFLICT DO NOTHING makes this safe and idempotent, and it never overwrites rows you already translated.
