Edge cases + gotchas

Help Tutorials

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_reads has 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> returns null → no icon, no auto-open.
  • Backend /admin/help-tutorials/* returns 403 with module_disabled.
  • The help_tutorials and help_tutorial_reads tables 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:

  1. Try (page_key, user_locale).
  2. If miss AND user_locale is not en, try (page_key, en).
  3. 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.