@bsuite/nav-core
v1.4.0
Published
App-agnostic navigation primitives for the BSuite apps: the sidebar state, the app switcher, the filtered nav, Speed Insights route naming — and, from 1.3.0, the platform update notice and the unsaved-work guard.
Readme
@bsuite/nav-core
App-agnostic navigation primitives for the BSuite apps: the sidebar state, the app switcher, the filtered nav, Speed Insights route naming — and, from 1.3.0, the platform update notice and the unsaved-work guard.
Ships as an npm package because Vercel deploys each BSuite app from its own
GitHub repo — the parent monorepo's packages/ directory does NOT exist in the
Vercel build context. Consumers install from npm with a semver range.
Development preview fixes use an exact published prerelease pin. The mobile
drawer and app-switcher corrections are staged as 1.4.0-next.0; they reach
consumers only after the development publisher succeeds and the consumer's
manifest and frozen lockfile resolve that version. The next publication does
not change latest, and production promotion requires the stable release.
Update notice and unsaved-work guard (v1.3.0+)
Operator directive 2026-09-03 10:48: "note there are people using the app in production i.e. actual clients so stage all work to development branch and then ensure their is a platform notice and refresh to update prompt so they dont lose work on rebuilds."
Four exports, one contract: a rebuild announces itself; nothing reloads a
client's tab without their consent (precedent
precedent__bsuite__20260903__no_code_path_reloads_a_clients_tab_without_consent).
| Export | Entry point | What it does |
|---|---|---|
| useAppUpdateAvailable() | @bsuite/nav-core | Compares the commit baked into the RUNNING bundle (__BUILD_COMMIT__) with the commit at /version.json. Any difference is versionChanged. Never reloads. |
| <UpdateAvailableBanner/> | @bsuite/nav-core | role="status" aria-live="polite", role tokens only, one Refresh button (confirms when a form is dirty), Dismiss (hidden when severity: 'critical'). data-slot="update-available-banner" for the mount gate. |
| useUnsavedChanges() / useRegisterDirty() | @bsuite/nav-core | A module-level dirty registry that arms ONE beforeunload while anything is dirty, plus confirmLeave(message?: string) for action-specific departure confirmation; no argument retains the banner’s Refresh wording. |
| versionJsonPlugin() / writeVersionJson() | @bsuite/nav-core/vite | The BUILD half: defines __BUILD_COMMIT__ and emits dist/version.json from ONE resolver. Node-only — never import it from browser code. |
How detection works
- The plugin resolves the commit ONCE per build:
VERCEL_GIT_COMMIT_SHA, thenGIT_COMMIT, thengit rev-parse HEAD. With none of the three it throws — a build that cannot identify itself cannot tell a client their tab is out of date, and a placeholder such as'local'makes the check permanently silent while every gate stays green. PassallowUnknownCommit: mode !== 'production'for local dev, where the plugin then defines nothing, emits nothing, and the hook reportsupdateCheckBroken: 'no-build-commit'and does nothing. - The comparison is exact: both halves come from the same resolver at the
same
commitLength, so a length mismatch means an app kept a hand-rolleddefine.__BUILD_COMMIT__beside the plugin. Remove the hand-rolled one. - Any difference is a change. The copy never says "new": Vercel's Instant Rollback moves the deployed commit backwards, and a tab on the rolled-back-from build is just as out of date.
- Polling: on mount, every 10 minutes (
intervalMs), and on everyvisibilitychangeto visible.fetch(url, { cache: 'no-store' }). - A broken check is loud, never silent: the
content-typeis asserted to start withapplication/jsonBEFORE the body is parsed. A mismatch logs oneconsole.error(per distinct fault, not per poll), setsupdateCheckBroken('not-json' | 'bad-status' | 'malformed' | 'unreachable' | 'no-build-commit') and keeps polling. Today a missing/version.jsonanswers200 text/htmlon every Vite app in this estate (the SPA catch-all rewrite) — that is the failure this assertion exists to surface. - Dismissal is keyed by the detected commit in
sessionStorage(bsuite:update-dismissed:<commit>), so a later deploy re-shows the notice and a fresh tab starts undismissed. An escalation (a chunk failing to load) clears the dismissal for the current commit. - Control (
{ enabled, severity }) is read by the app from its existing system-notice source (slugupdate-notice) and passed straight through.enabled: falseis the kill switch — no banner, no polling, no deploy needed.severity: 'critical'cannot be dismissed and re-shows on every poll.
Adoption snippet (Vite apps)
// vite.config.ts
import { versionJsonPlugin } from '@bsuite/nav-core/vite'
export default defineConfig(({ mode }) => ({
plugins: [
react(),
versionJsonPlugin({ allowUnknownCommit: mode !== 'production' }),
],
// REMOVE any hand-rolled `define: { __BUILD_COMMIT__: ... }` — the plugin owns it.
}))// the app shell — wherever the SystemNoticeBanner already sits
import { UpdateAvailableBanner } from '@bsuite/nav-core'
<AppShell
banner={
<>
<SystemNoticeBanner />
<UpdateAvailableBanner
control={updateNotice /* { enabled, severity } from system_notices slug 'update-notice' */}
onRefresh={purgeStaleAppCachesThenReload /* crm7 only; others omit -> location.reload() */}
update={{
runningCommit: __BUILD_COMMIT__, // belt-and-braces: the app's own define site
onSignal: (raise) => { // immediate triggers the app already has
const onPreload = () => raise('vite:preloadError')
window.addEventListener('vite:preloadError', onPreload)
return () => window.removeEventListener('vite:preloadError', onPreload)
},
}}
/>
</>
}
/>// any form with real work in it
import { useRegisterDirty } from '@bsuite/nav-core'
useRegisterDirty(`apprentice-edit:${id}`, form.formState.isDirty)For a Next.js app (conduit) there is no generateBundle; serve the same shape
from a route handler and share the writer so the two shapes cannot drift:
// src/app/version.json/route.ts
import { resolveBuildInfo } from '@bsuite/nav-core/vite' // server-only file
export const dynamic = 'force-static' // resolved once, at build
const info = resolveBuildInfo({ commitLength: 'short' })
if (!info) throw new Error('version.json: no build commit resolved (VERCEL_GIT_COMMIT_SHA / GIT_COMMIT / git)')
export function GET() {
return Response.json(info, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
})
}The rewrite exclusion (every Vite app's vercel.json)
A missing /version.json must 404 honestly, not answer the SPA index page.
Add version\.json to the rewrite's negative-lookahead (crm7 / R80.4 shape), or
an explicit exclusion in the catch-all shape (BSU / braden / throughput):
// negative-lookahead shape (crm7, R80.4)
{ "source": "/((?!api/|assets/|version\\.json|...).*)", "destination": "/index.html" }
// catch-all shape (business-suite-unified, braden, throughput)
{ "source": "/((?!version\\.json).*)", "destination": "/index.html" }The Cache-Control rule
/version.json must never be served from a CDN or browser cache — a cached
answer is the OLD build's answer, and the check silently agrees with the tab it
is meant to correct. Every app's vercel.json already carries this for
/index.html; add the same for /version.json, and make sure it does NOT
fall under an /assets/* immutable rule:
{
"source": "/version.json",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
}(conduit: the same via next.config.ts headers() or the route handler's
response headers, and /version.json added to publicMetadataPaths in
src/lib/supabase/middleware.ts so it is not redirected to login.)
The smoke check
Before trusting the banner on any domain, check what /version.json actually
returns there. Anything but 200 + application/json means the notice is
wired to nothing:
for d in suite crm conduit ideas r8 d.suite d.crm d.conduit d.ideas d.r8; do
printf '%-12s ' "$d"
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' "https://$d.crm7.app/version.json"
done
# braden.com.au and d.braden.com.au likewiseThe parent repo's scripts/check-version-json-served.mjs runs this over all
12 domains post-deploy and nightly. Also: open a tab, deploy a different
commit, and confirm the banner appears within one poll without the tab
reloading (a window.__sentinel = Date.now() set in the console must survive).
What this package does NOT do
- It never reloads. The only reload is the banner's Refresh button, after
confirmLeave(). - It does not touch a service worker. crm7's
VitePWAconfig stays as it is; crm7 passespurgeStaleAppCachesThenReloadasonRefresh. - It does not remove the existing unconsented reload sites in the apps
(BSU
ErrorBoundary.tsx:59, crm7App.tsx:1146-1151and:1155, throughputErrorBoundary.tsx:73). Each app's adoption PR deletes those and routes the signal intoonSignalinstead.
Other exports
App switcher accessibility
AppSwitcher names its trigger Switch app, current: <shortName> (or Switch app
when there is no current app). The name remains available when the visible short
name is hidden on narrow screens and includes that label for speech control.
Opening the menu and closing it with Escape retain the existing menu semantics.
Consumers receive this correction only after publishing and installing the package.
Development launches
resolveSuiteAppUrl(url) keeps launches from the six stable d.* suite domains
on the corresponding development destination when a consumer supplies a canonical
production fallback. AppSwitcher resolves both cross-app and current-app menu rows
when opened in the browser. Braden maps to d.braden.com.au, including its production apex alias.
Explicit custom domains, localhost, existing development URLs and unrelated Vercel
previews remain configured as supplied. Production and server-side rendering retain
production routing. Deep-link paths, query values and fragments survive host resolution.
OAuth URL builders remain deterministic: host-aware callers resolve their destination
explicitly, so server-rendered links do not change during hydration.
This shared boundary avoids six competing environment rewrites; development launch checks must verify the resulting origin as well as sign-in and the landing route.
useSidebarState, useFilteredNav, useSpeedInsightsRoute /
speedInsightsRoute, AppSwitcher, MobileSidebarDrawer, launchUrl,
sanitizeReturnPath, cookieStorage, merge, and the sidebar token CSS at
@bsuite/nav-core/tokens/d2c-sidebar.css and .../corporate-sidebar.css. See
src/index.ts for the full surface and each file's docblock for its contract.
Drawer visibility breakpoint
MobileSidebarDrawer defaults to md:hidden. Apps with a tablet layout that
uses the drawer through the lg breakpoint can pass
visibilityClassName="lg:hidden" and focusReturnTargetRef pointing to the
visible desktop navigation item. Move the menu trigger, permanent sidebar,
and any sidebar-dependent content offsets to the same breakpoint. Keep the
chosen utility visible to the consumer's Tailwind source scan. When the open
drawer crosses that boundary, it closes through onClose, returns focus to
the desktop target, and releases the body scroll lock. Without the focus ref,
the existing resize behavior is preserved. Boundary-close queries match
Tailwind's default md (48rem) and lg (64rem) widths; custom breakpoint
themes need a matching query before enabling automatic close.
Releases
See CONTRIBUTING.md. Merging a package.json version bump to main runs
.github/workflows/publish-nav-core.yml (npm Trusted Publishers, OIDC).
NEVER use workspace:* or file:../packages/nav-core in a consumer —
Vercel builds cannot resolve those.
