Skip to content

Frontend Architecture

How the OhShii Launcher frontend (src/frontend/) is built: the stack, the JS + JSDoc type-checking model, the TypeScript version policy, the two canister bridges, Candid wire conventions, auth providers, and the design system.

This page is the map of how the frontend is put together — read it before adding a page, component, or canister call. It describes the architecture; the canonical implementations live in the files it points to (src/frontend/tsconfig.json, types/canister-types.js, utils/canister.js, components/ui/). When this page and the code disagree, the code wins — fix this page.


ConcernChoiceNotes
UI libraryReact 18 (^18.2.0)function components + hooks only
Build toolVite 7 (^7.0.6)dev server + production bundle; strips types, does not type-check
Source languagePlain JavaScript (.js / .jsx)no .ts/.tsx application source
TypesJSDoc + // @ts-checktype checking, not type compilation
Type checkerTypeScript (^5.3.3, lockfile → 5.8.3)dev-only; never emits — noEmit: true
StylingTailwind CSS + design tokenssee “Design system” below
IC client@dfinity/agent / candid / principal (^2.x)actors, certificates, read_state

There is no separate src/frontend/package.json — the frontend shares the repo-root package.json and its single node_modules. The frontend tsconfig lives at src/frontend/tsconfig.json.


Why JavaScript + JSDoc (not TypeScript source)

Section titled “Why JavaScript + JSDoc (not TypeScript source)”

Application code is plain .js / .jsx. Types are expressed in JSDoc comments and checked by tsc in checkJs mode. Files opt in with a // @ts-check pragma at the top (a few legacy files opt out with // @ts-nocheck).

// @ts-check
/** @typedef {import('../types/canister-types').Campaign} Campaign */
const [campaign, setCampaign] = useState(/** @type {Campaign | null} */ (null));

The tsconfig that drives this:

// src/frontend/tsconfig.json (excerpt)
{
"compilerOptions": {
"checkJs": true, // type-check .js/.jsx
"allowJs": true,
"noEmit": true, // never emit — Vite does the bundling
"strict": false, // relaxed during the JSDoc migration
"strictNullChecks": false,
"moduleResolution": "bundler",
"skipLibCheck": true,
"paths": {
"@/*": ["../src/*"],
"@declarations/*": ["../declarations/*"]
}
}
}

Consequences worth internalizing:

  • Vite never type-checks. A type error does not break npm run dev or the production build. The only thing that catches type errors is the gate below.
  • strict is off. Many null-related errors are intentionally suppressed while the codebase finishes its JSDoc migration. Do not rely on strict-null-checking to catch bugs here.
  • Actors are any past the IDL bridge. tsc cannot see a missing canister method — that is a runtime failure. See the two bridges below.

After any frontend change, from the repo root:

Terminal window
npx tsc --noEmit -p src/frontend

This must be clean before merge. It runs the workspace TypeScript (see the version policy next) and is the single source of truth for “does the frontend type-check”.


TypeScript version policy (read this before trusting a red squiggle)

Section titled “TypeScript version policy (read this before trusting a red squiggle)”

The source of truth is the typescript version pinned in the root package.json devDependencies (currently the 5.x line; the lockfile resolves 5.8.3). The gate npx tsc --noEmit -p src/frontend runs that binary from node_modules. If the gate is green, the frontend type-checks — full stop.

Why your editor may disagree (the phantom-error trap)

Section titled “Why your editor may disagree (the phantom-error trap)”

VSCode (and other IDEs) ship their own bundled TypeScript, which is often newer than the workspace version. When the two diverge, the editor can paint a file red with errors that the gate does not report — and that are not real bugs.

Concrete, real recurrence: utils/canisterModuleHash.js showed two errors in the editor while tsc -p src/frontend was clean. Root cause: the workspace TS (5.8.3) treated SharedArrayBuffer as assignable to ArrayBuffer, but the editor’s bundled TS (6.0.x) brand-differentiates them (the TS ≥5.7 generic Uint8Array<TArrayBuffer extends ArrayBufferLike> change). A helper returning bytes.buffer.slice(...) is typed ArrayBufferLike (ArrayBuffer | SharedArrayBuffer) under the newer compiler, which no longer satisfies @dfinity/agent’s bare-ArrayBuffer parameters (ReadStateOptions.paths, Certificate.lookup, Certificate.create). At runtime these byte sources (TextEncoder.encode, Principal.toUint8Array) are always ArrayBuffer-backed, so the SharedArrayBuffer branch is a pure type-level artifact — a phantom error. The fix was a narrow @returns {ArrayBuffer} + JSDoc cast on the helper.

  1. The gate is authoritative, not the editor. When the editor shows red but npx tsc --noEmit -p src/frontend is clean, suspect a version skew before suspecting your code. Reproduce against the workspace version: ./node_modules/.bin/tsc --noEmit -p src/frontend.
  2. Make the editor use the workspace version. In VSCode, run “TypeScript: Select TypeScript Version” → “Use Workspace Version”, or add .vscode/settings.json:
    { "typescript.tsdk": "node_modules/typescript/lib" }
    This aligns the language server with the gate and eliminates phantom ArrayBufferLike-class errors. (No .vscode/ directory is committed today — adding this file is the recommended fix.)
  3. Bumping the workspace TypeScript is a coordinated change. A newer tsc surfaces stricter checks (the ArrayBuffer brand split above is one). If you raise the typescript devDependency, re-run the full gate and fix every new diagnostic in the same change — do not bump and leave the gate red.
  4. Generated declarations must import from @dfinity/*, never @icp-sdk/core. This is enforced by the Deploy/docker-build.sh pre-check — it greps src/declarations and fails the build on any @icp-sdk/core hit. A newer didc / bindgen emits @icp-sdk/core imports that clash nominally with the @dfinity/* types the app actually depends on — same family of version-skew breakage, different surface. When you regenerate declarations, verify the head import lines still read @dfinity/*.

PathPurpose
index.jsx, App.jsxSPA entry + top-level routing
pages/Route-level screens (e.g. IcoDetailsPage, OnsVotingPage, DaoDetailsPage, SystemStatusPage)
components/Reusable components; components/ui/ holds the design-system primitives (Card, Modal, Pill, Tabs, CopyButton, Skeleton, …)
hooks/Custom React hooks (e.g. useShieldStatus, useCanisterHealth, useSlidingTabIndicator)
contexts/React context providers (auth / wallet session, etc.)
utils/Non-React helpers. utils/canister.js is the IDL + actor bridge (see below)
types/canister-types.js (type bridge), domain.d.ts (UI/domain/REST types), window.d.ts, candid.js
config/Static config / canister IDs
workers/Web Workers (e.g. the Shield PoW worker)
assets/, styles/CSS, design-token stylesheets, images

The two bridges (every canister call funnels through both)

Section titled “The two bridges (every canister call funnels through both)”

This is the load-bearing contract of the frontend. The canonical implementations are the two files named in the subsections below; the summary:

Backend .did
→ dfx generate / didc
→ src/declarations/<canister>/<canister>.did.d.ts (generated, @dfinity/* imports only)
→ src/frontend/src/types/canister-types.js (TYPE bridge)
→ src/frontend/src/types/domain.d.ts (UI / domain / REST view-models)
→ components / pages / utils

Bridge 1 — types/canister-types.js (types, compile-time)

Section titled “Bridge 1 — types/canister-types.js (types, compile-time)”

Single source of truth for every Candid type the frontend touches. Every record / variant / result-alias / _SERVICE used outside this file is re-exported here as a @typedef from the generated .did.d.ts. Components import the alias from the bridge, never from @declarations/* directly (the only exception is _SERVICE types used to annotate ActorSubclass<_SERVICE>).

Caught by: npx tsc --noEmit -p src/frontend (missing field, snake_case ↔ camelCase mismatch, missing [] on opt T, bigint/number arithmetic).

Bridge 2 — utils/canister.js (IDL + actors, runtime)

Section titled “Bridge 2 — utils/canister.js (IDL + actors, runtime)”

Single source of truth for IDL.Service({...}) factories and getXxxActor(...) helpers. Every page/component that needs an actor goes through a helper (getBackendActor, getStorageActor, getLedgerActor, getOhshiiGovernanceActor, createActorWithPlugSupport, …). Never define an IDL.Service({...}) inside a page, and never call Actor.createActor outside this module (the one documented exception is utils/onsLockCreation.js).

Caught by: nothing automatic. A method missing from the IDL is invisible to tsc (actors are any past the bridge) and surfaces only at runtime as TypeError: <minified>.<method> is not a function. This is why widening a flow (enabling a button, swapping a method, upgrading a canister .did) requires checking every actor.method(...) the path reaches — not just the one you edited.

types/domain.d.ts (UI / domain / external types)

Section titled “types/domain.d.ts (UI / domain / external types)”

Component props, transformed view-models, REST payloads (e.g. ICPSwap), browser-only shapes, and AuthSource. It may import Candid types via import('./canister-types') but never imports @declarations directly.


Candid wire conventions (do not “fix” these)

Section titled “Candid wire conventions (do not “fix” these)”

The generated types mirror the wire format exactly:

  • Fields are snake_case. campaign.campaign_id, sponsor.sponsor_code — never campaignId. The field name is the contract.
  • opt T[] | [T]. Access with result.opt_field[0], not .opt_field.
  • nat / nat64bigint. Use 1000n literals; never do number arithmetic on a ledger amount.
  • Lowercase Candid variant labels (variant { ok; err } from Motoko / ICPSwap) are case-sensitive and are not Ok/Err. Decode/destructure against the exact casing (it bites on the backend Rust side too).

The app supports three auth sources — Internet Identity (II), NFID (authenticatedAgent), and Plug Wallet (plugSession) — and every canister call must work for all three. The discipline that keeps them working:

  • Use the centralized getXxxActor(...) helpers in utils/canister.js. Do not reimplement HttpAgent/actor construction in a page.
  • Never pass a Plug session as new HttpAgent({ identity }) — a Plug session is { agent, principalId, accountId }, not an Identity; use its .agent.
  • Use getLedgerActor(...) for ICP transfers/approvals — it routes through Plug’s createActor so signing and the canister whitelist work.
  • For NFID, call canisters with authenticatedAgent (an Agent), not the legacy identity-like object.
  • When aggregating “current auth”, prefer the order plugSession || authenticatedAgent || identity.
  • Plug does not work for transfers on the local network (certificate verification) — guard those flows and tell the user to use II locally.

The visual language — design tokens, the section-panel / stat-row / pill / carved-tile patterns, the CopyButton security-verification modal, colour roles, CLS-safe async loading — is captured in the primitives under components/ui/ and the Tailwind config / design-token stylesheets in assets/ and styles/. Build new UI from those primitives and match the existing components (TokenInformation, DaoInformation, TokenPricingChart, DaoDetailsPage) so the launcher and locker stay visually coherent rather than re-rolling one-off panels.


  • ARCHITECTURE.md — Canister map and governance scope table.
  • WORKFLOWS.md — Create LGE, token, vote, create/execute proposal, backend vs frontend upgrade, self-snapshot flows.
  • GOVERNANCE.md — ONS vs SONS, proposal categories, snapshot/restore.
  • OHSSHII_LOCKER_INTEGRATION.md — OhShii Locker token list and lock management via ONS proposals, SONS vesting vs Locker.
  • LOGGING_SYSTEM.md — Three-tier logging architecture, stable memory log details, frontend monitoring.
  • WORLD_ID_VERIFICATION.md — World ID voter verification: flow, privacy model, three-state config, rate limits, governance controls and operator checklist.