Skip to content

Instantly share code, notes, and snippets.

@aaronshaf
Created November 13, 2025 16:23
Show Gist options
  • Select an option

  • Save aaronshaf/2ae17c4073c4eb5aae6451c3ce789811 to your computer and use it in GitHub Desktop.

Select an option

Save aaronshaf/2ae17c4073c4eb5aae6451c3ce789811 to your computer and use it in GitHub Desktop.

Canvas LMS Monorepo - Architectural Analysis Report

Generated: 2025-11-13 Repository: instructure/canvas-lms Analysis Scope: Frontend architecture, tooling, and development patterns


Table of Contents

  1. Repository Structure & Organization
  2. Tooling & Development Standards
  3. Package Management & Dependencies
  4. Component & API Design Patterns
  5. Frontend Architecture Decisions
  6. Development Workflow & Quality
  7. Build & Deployment

1. Repository Structure & Organization

1.1 Overall Directory Structure

Canvas LMS is a hybrid Rails + React monorepo with the following high-level organization:

canvas-lms/
├── app/                    # Rails MVC (models, controllers, views, GraphQL)
├── ui/                     # Frontend code (React, TypeScript)
│   ├── boot/              # Application initialization & routing
│   ├── features/          # Feature-specific React apps (~231 features)
│   ├── shared/            # Reusable components & utilities (~228 packages)
│   └── engine/            # Core UI engine
├── packages/              # Shared NPM packages (~39 packages)
├── gems/plugins/          # Canvas plugins (~14 plugins)
├── lib/                   # Ruby business logic
├── config/                # Rails configuration
├── public/                # Static assets & legacy JavaScript
├── ui-build/              # Build tooling & configuration
├── spec/                  # Ruby tests
└── node_modules/          # NPM dependencies

Key Metrics:

  • ~231 feature apps in ui/features/
  • ~228 shared packages in ui/shared/
  • ~39 npm packages in packages/
  • ~14 Rails plugins in gems/plugins/

1.2 Package Naming & Categorization

UI Shared Packages (ui/shared/)

Named with @canvas/ scope via import aliases:

  • ui/shared/apollo-v3@canvas/apollo-v3
  • ui/shared/block-editor@canvas/block-editor
  • ui/shared/do-fetch-api-effect@canvas/do-fetch-api-effect

Structure:

// ui/shared/block-editor/package.json
{
  "name": "@canvas/block-editor",
  "private": true,
  "version": "1.0.0",
  "main": "./react/index.tsx",
  "owner": "RCX"
}

Standalone Packages (packages/)

Mix of publishable and internal packages:

  • @instructure/canvas-rce - Rich Content Editor (publishable to npm)
  • @instructure/canvas-media - Media utilities (publishable)
  • canvas-rce/ - Companion package
  • jquery, jqueryui - Legacy wrappers (internal)

Publishable Example:

// packages/canvas-rce/package.json
{
  "name": "@instructure/canvas-rce",
  "version": "7.3.1",
  "main": "es/index.js",
  "types": "es/index.d.ts",
  "owner": "RCX"
}

Feature Apps (ui/features/)

Named as private packages with feature-specific naming:

  • ui/features/assignments/ - Assignment management
  • ui/features/gradebook/ - Gradebook feature
  • ui/features/speed_grader/ - SpeedGrader (legacy)

Structure:

ui/features/assignments/
├── react/              # React components
│   └── __tests__/     # Component tests
├── graphql/           # GraphQL queries
├── index.tsx          # Feature entry point
└── package.json       # Feature metadata

1.3 Structural Patterns: Apps vs Libraries

Apps (Features)

  • Location: ui/features/
  • Characteristics:
    • Self-contained feature implementations
    • Have entry points that mount to DOM
    • Feature-specific routing
    • Can depend on shared libraries
    • Not reusable across features

Entry Point Pattern:

// ui/features/assignments/index.tsx
import ready from '@instructure/ready'
import React from 'react'
import ReactDOM from 'react-dom'
import AssignmentApp from './react/AssignmentApp'

ready(() => {
  const container = document.getElementById('assignment-container')
  if (container) {
    const root = ReactDOM.createRoot(container)
    root.render(
      <AssignmentApp
        courseId={ENV.course_id}
        permissions={ENV.permissions}
      />
    )
  }
})

Libraries (Shared)

  • Location: ui/shared/ and packages/
  • Characteristics:
    • Reusable utilities and components
    • No DOM mounting logic
    • Exported via named exports
    • TypeScript-first
    • Used by multiple features

Library Pattern:

// ui/shared/block-editor/react/BlockEditor.tsx
export {BlockEditor} from './components/BlockEditor'
export {useBlockEditor} from './hooks/useBlockEditor'
export type {BlockEditorProps} from './types'

1.4 Package Boundary Enforcement

Import Path Aliases

Configured in tsconfig.json:

{
  "compilerOptions": {
    "baseUrl": "./ui",
    "paths": {
      "@canvas/*": ["shared/*"],
      "@instructure/*": ["./packages/*"]
    }
  }
}

Barrel Exports

Packages expose public APIs via index files:

// ui/shared/block-editor/react/index.tsx
export {BlockEditor} from './components/BlockEditor'
export type {BlockEditorProps} from './types'
// Internal utilities are NOT exported

No Explicit Enforcement

  • No dependency-cruiser rules preventing cross-feature imports
  • No private/public API markers in package.json
  • Convention-based rather than tool-enforced
  • Features should not import from other features
  • Features can import from @canvas/* and @instructure/*

2. Tooling & Development Standards

2.1 Linting Tools

ESLint (Primary)

Configuration: eslint.config.js (Flat Config format)

Key Plugins:

  • @eslint/js - Core ESLint rules
  • typescript-eslint - TypeScript support
  • eslint-plugin-react - React rules
  • eslint-plugin-react-hooks - Hooks linting
  • eslint-plugin-jsx-a11y - Accessibility checks
  • eslint-plugin-import - Import/export validation
  • eslint-plugin-lodash - Lodash best practices
  • eslint-plugin-promise - Promise handling
  • eslint-plugin-react-compiler - React Compiler compatibility

Scope:

// eslint.config.js
{
  files: ['ui/**/*.{js,mjs,ts,jsx,tsx}', 'ui-build/**/*.{js,mjs,ts,jsx,tsx}'],
  ignores: [
    '**/doc/**',
    '**/es/**',
    '**/ui/shared/jquery/**',
    '**/*.config.*',
    '**/jest/**',
  ]
}

Notable Rules:

  • prefer-const: 'warn' - Encourage immutability
  • @typescript-eslint/no-explicit-any: 'off' - Allows any (pragmatic)
  • react/jsx-uses-react: 'off' - New JSX transform
  • react-hooks/rules-of-hooks: 'error' - Strict hooks rules
  • notice/notice: 'error' - Copyright header enforcement

Per-Package Overrides: Some packages have custom ESLint configs:

  • packages/canvas-rce/eslint.config.js
  • ui/features/quiz_log_auditing/eslint.config.js

Biome (Formatting Only)

Configuration: biome.json

{
  "formatter": {
    "enabled": true,
    "indentWidth": 2,
    "indentStyle": "space"
  },
  "linter": {
    "enabled": false  // Only formatting, not linting
  },
  "javascript": {
    "formatter": {
      "semicolons": "asNeeded",
      "lineWidth": 100,
      "quoteStyle": "single",
      "bracketSpacing": false,
      "arrowParentheses": "asNeeded"
    }
  }
}

Usage:

  • yarn check:biome - Check formatting on changed files
  • yarn biome format --write - Format files
  • Biome is newer addition, coexisting with ESLint

2.2 TypeScript Configuration

Single Root Config Strategy

File: tsconfig.json

{
  "compilerOptions": {
    "allowJs": true,                    // JS files allowed
    "strict": true,                     // Strict mode enabled
    "noImplicitAny": true,             // Explicit any required
    "noEmit": true,                    // Type-checking only
    "skipLibCheck": true,              // Skip .d.ts checking
    "target": "es2020",
    "lib": ["DOM", "ES2020", "ESNext"],
    "jsx": "react-jsx",                // New JSX transform
    "module": "es2020",
    "moduleResolution": "node",
    "baseUrl": "./ui",
    "paths": {
      "@canvas/*": ["shared/*"],
      "@instructure/*": ["./packages/*"]
    }
  },
  "include": ["ui/**/*.ts", "ui/**/*.tsx"],
  "exclude": ["node_modules"]
}

Key Decisions:

  • Strict mode enabled - High type safety
  • allowJs: true - Gradual migration from JS
  • No per-package tsconfigs - Single unified config
  • Path aliases for clean imports
  • No composite projects - Simpler setup

Individual Package TypeScript: Some packages have their own tsconfig.json for building (not type-checking):

// packages/canvas-rce/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./es",
    "declaration": true
  }
}

2.3 Monorepo Orchestration

Yarn Workspaces (v1.19.1)

Configuration: package.json

{
  "private": true,
  "workspaces": {
    "packages": [
      "gems/plugins/*",
      "packages/*",
      "ui/engine",
      "ui/shared/*"
    ]
  }
}

No Additional Tools:

  • ❌ No Turborepo
  • ❌ No Nx
  • ❌ No Lerna
  • ✅ Just Yarn Workspaces + custom scripts

Task Running: Uses wsrun for parallel workspace commands:

{
  "scripts": {
    "build:packages": "wsrun --fast-exit --exclude-missing --report -c build",
    "test:packages": "wsrun --report -m -l -s -c test"
  }
}

2.4 Testing Frameworks

Jest (Primary)

Configuration: jest.config.js

module.exports = {
  testMatch: ['**/__tests__/**/?(*.)(spec|test).[jt]s?(x)'],
  testEnvironment: 'jest-fixed-jsdom',
  transform: {
    '^.+\\.(j|t)sx?$': ['@swc/jest', {...}]  // SWC for speed
  },
  setupFiles: [
    'jest-localstorage-mock',
    'jest-canvas-mock',
    '<rootDir>/jest/jest-setup.js',
  ],
  setupFilesAfterEnv: [
    '<rootDir>/jest/stubInstUi.js',
    '@testing-library/jest-dom',
  ],
  moduleNameMapper: {
    '\\.svg$': '<rootDir>/jest/imageMock.js',
    '\\.(css)$': '<rootDir>/jest/styleMock.js',
  },
  testTimeout: 10000
}

Key Features:

  • @swc/jest for fast transpilation
  • @testing-library/react for component testing
  • jest-fixed-jsdom for DOM environment
  • MSW for network mocking (not fetch-mock)
  • jest-junit reporter for CI

Vitest (Newer Tests)

Configuration: vitest.config.ts

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: 'ui/setup-vitests.tsx',
    include: ['ui/**/__tests__/**/*.test.?(c|m)[jt]s?(x)'],
    coverage: {
      include: ['ui/**/*.ts?(x)', 'ui/**/*.js?(x)'],
      reportOnFailure: true,
    }
  }
})

Adoption Status:

  • Newer addition to the codebase
  • Runs alongside Jest (not replacing it yet)
  • Used for some ui/ tests

Test Commands:

{
  "scripts": {
    "test": "jest --color",
    "test:jest": "jest --color",
    "test:vitest": "vitest run --color",
    "test:coverage": "script/generate_js_coverage"
  }
}

2.5 Sourcemaps

Production Sourcemaps

Configuration: ui-build/webpack/index.js

devtool: skipSourcemaps
  ? false
  : isProduction || process.env.COVERAGE === '1'
    ? 'source-map'        // Full sourcemaps for production
    : 'eval-source-map'   // Fast sourcemaps for dev

Key Points:

  • Full sourcemaps in production (source-map)
  • Fast sourcemaps in dev (eval-source-map)
  • Can be disabled with SKIP_SOURCEMAPS=1
  • Sentry integration for error tracking

Sentry Configuration:

{
  "dependencies": {
    "@sentry/react": "^7.81.0",
    "@sentry/cli": "^2.21.5"
  }
}

3. Package Management & Dependencies

3.1 Package Manager

Yarn Classic (v1.19.1)

{
  "engines": {
    "node": ">=20.0.0",
    "yarn": "^1.19.1"
  },
  "packageManager": "[email protected]+sha512.8019df6cbf6b618d391add1c8c986cfec8aa4171d89596a54e32b79d79f640edb4c5b90814fa1bf8b947e3830be3b19c478554f7fd9d61c93505614cd096afc7"
}

Why Yarn Classic:

  • Not Yarn Berry/PnP - Uses standard node_modules
  • Workspaces support for monorepo
  • Lock file: yarn.lock (committed to repo)

3.2 Shared Dependencies Management

Dependency Deduplication

Tool: yarn-deduplicate

{
  "scripts": {
    "dedupe-yarn": "yarn yarn-deduplicate",
    "postinstall": "yarn dedupe-yarn; patch-package; ..."
  }
}

Automatically deduplicate dependencies after install to reduce bundle size.

Version Pinning via Resolutions

Strategy: Use resolutions field to force specific versions

{
  "resolutions": {
    "punycode": "npm:[email protected]",
    "jsdom": "25.0.1",
    "@instructure/emotion": "10.26.2",
    "@instructure/ui-icons": "10.26.2",
    "@instructure/ui-buttons": "10.26.2",
    // ... 30+ more resolutions
  }
}

Purpose:

  • Ensure consistent InstUI versions across packages
  • Force security patches
  • Workaround for hoisting issues

Shared UI Library: Instructure UI

All InstUI packages pinned to 10.26.2:

{
  "dependencies": {
    "@instructure/ui-buttons": "10.26.2",
    "@instructure/ui-icons": "10.26.2",
    "@instructure/ui-modal": "10.26.2",
    "@instructure/ui-select": "10.26.2",
    // ... 60+ InstUI packages at same version
  }
}

Update Script:

yarn upgrade-instructure-ui  # Custom script in script/upgrade-instructure-ui

3.3 Versioning & Release Patterns

Internal Packages (ui/shared/*)

Pattern: All private, no versioning

{
  "name": "@canvas/block-editor",
  "private": true,
  "version": "1.0.0"  // Version never changes
}

No release process - changes deployed as part of Canvas

Publishable Packages (packages/*)

Pattern: Semantic versioning + npm publishing

// packages/canvas-rce/package.json
{
  "name": "@instructure/canvas-rce",
  "version": "7.3.1",  // Semantic versioning
  "scripts": {
    "prepublishOnly": "yarn build && yarn test",
    "publishToNpm": "scripts/publish_to_npm.sh"
  }
}

Release Process:

  1. Manual version bump in package.json
  2. Build and test via prepublishOnly
  3. Custom publish script (scripts/publish_to_npm.sh)
  4. No automated changelog generation

No Changesets

Observation: No evidence of Changesets or similar tools:

  • ❌ No .changeset/ directory
  • ❌ No changeset packages in dependencies
  • ✅ Manual version management

3.4 Notable Dependency Patterns

React 18

{
  "dependencies": {
    "react": "^18",
    "react-dom": "^18"
  }
}

GraphQL Stack

{
  "dependencies": {
    "@apollo/client": "3.12.4",
    "graphql": "^16",
    "graphql-request": "^7.1.2",
    "graphql-ws": "^6.0.4"
  },
  "devDependencies": {
    "@graphql-codegen/cli": "^5.0.6",
    "@graphql-codegen/schema-ast": "^4.1.0"
  }
}

State Management

{
  "dependencies": {
    "@tanstack/react-query": "^5.74.4",
    "redux": "^4.0.1",
    "zustand": "^4.5.5"
  }
}

Legacy Dependencies (Still Used)

{
  "dependencies": {
    "backbone": "1.1.1",              // Very old version!
    "jquery3": "npm:jquery@^3.7.1",   // Aliased
    "jquery-migrate": "3.4.1",
    "underscore": "^1.13.6",
    "moment": "^2.29.4",
    "d3": "3.5.17"                     // Also old
  }
}

4. Component & API Design Patterns

4.1 Component Configuration

Props-Based Configuration

Components receive configuration via React props:

// ui/features/assignments/react/AssignmentApp.tsx
interface AssignmentAppProps {
  courseId: string
  assignmentId?: string
  permissions: {
    create: boolean
    edit: boolean
  }
}

export function AssignmentApp({courseId, assignmentId, permissions}: AssignmentAppProps) {
  // Component logic
}

ENV Object for Initial Data

Components access server-injected data via global ENV:

// Global type definition: ui/shared/global/env/GlobalEnv.d.ts
declare global {
  const ENV: GlobalEnv
}

// Usage in entry points
// ui/features/assignments/index.tsx
ready(() => {
  const root = ReactDOM.createRoot(document.getElementById('app')!)
  root.render(
    <AssignmentApp
      courseId={ENV.course_id}
      permissions={ENV.permissions}
      timezone={ENV.TIMEZONE}
      locale={ENV.LOCALE}
    />
  )
})

ENV Structure:

// ui/shared/global/env/GlobalEnv.d.ts (simplified)
export interface GlobalEnv extends EnvCommon {
  current_user_id?: string
  current_user?: User
  course_id?: string
  TIMEZONE: string
  LOCALE: string
  FEATURES: {
    [featureName: string]: boolean
  }
  permissions: {
    [permission: string]: boolean
  }
  // ... 100+ more properties
}

Context Providers (Rare)

Some features use React Context for configuration:

// Example from block editor
export const BlockEditorConfigContext = createContext<BlockEditorConfig>({
  canvasUrl: ENV.CANVAS_URL,
  userId: ENV.current_user_id,
})

export function BlockEditorProvider({children, config}: Props) {
  return (
    <BlockEditorConfigContext.Provider value={config}>
      {children}
    </BlockEditorConfigContext.Provider>
  )
}

4.2 Component Data Fetching

Pattern 1: Components Fetch Their Own Data

Most common pattern - components manage their own data fetching:

// ui/features/assignments/react/AssignmentList.tsx
import doFetchApi from '@canvas/do-fetch-api-effect'

export function AssignmentList({courseId}: Props) {
  const [assignments, setAssignments] = useState<Assignment[]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const fetchAssignments = async () => {
      const {json} = await doFetchApi({
        path: `/api/v1/courses/${courseId}/assignments`,
        params: {per_page: 50}
      })
      setAssignments(json)
      setLoading(false)
    }
    fetchAssignments()
  }, [courseId])

  if (loading) return <Spinner />
  return <div>{/* render assignments */}</div>
}

Pattern 2: Feature-Specific API Clients

Features centralize API calls in apiClient.js files:

// ui/features/announcements/react/apiClient.js
import axios from '@canvas/axios'

export function getAnnouncements({contextType, contextId}, options) {
  const url = `/api/v1/${contextType}s/${contextId}/discussion_topics`
  return axios.get(url, {params: options})
}

export function createAnnouncement({contextType, contextId}, data) {
  const url = `/api/v1/${contextType}s/${contextId}/discussion_topics`
  return axios.post(url, data)
}

// Usage in components
import {getAnnouncements} from './apiClient'

const fetchData = async () => {
  const response = await getAnnouncements({
    contextType: 'course',
    contextId: courseId
  }, {page: 1})
}

Examples:

  • ui/features/announcements/react/apiClient.js
  • ui/features/wiki_page_index/react/apiClient.js
  • ui/features/permissions/react/apiClient.js

Pattern 3: React Query Integration

Modern features use React Query for data fetching:

// ui/features/gradebook/react/AssignmentPostingPolicyTray/queries/queryFns/getAssignmentScheduledPost.tsx
import {executeQuery} from '@canvas/graphql'
import {gql} from 'graphql-tag'

const GET_ASSIGNMENT_SCHEDULED_POST = gql`
  query GetAssignmentScheduledPost($assignmentId: ID!) {
    assignment(id: $assignmentId) {
      id: _id
      scheduledPost {
        postCommentsAt
        postGradesAt
      }
    }
  }
`

export const getAssignmentScheduledPost = async ({queryKey}) => {
  const result = await executeQuery(GET_ASSIGNMENT_SCHEDULED_POST, {
    assignmentId: queryKey[1]
  })
  return result.assignment?.scheduledPost
}

// Hook wrapper
import {useQuery} from '@tanstack/react-query'

export const useAssignmentScheduledPost = (assignmentId: string) => {
  return useQuery({
    queryKey: ['assignment-scheduled-post', assignmentId],
    queryFn: getAssignmentScheduledPost,
    enabled: !!assignmentId
  })
}

Pattern 4: GraphQL with Apollo Hooks (Feature-Specific)

Some features use Apollo Client directly:

// ui/features/discussion_topics_post/react/containers/DiscussionTopicContainer.jsx
import {useMutation, useApolloClient} from '@apollo/client'
import {DELETE_DISCUSSION_TOPIC} from '../../graphql/Mutations'

export function DiscussionTopicContainer() {
  const [deleteDiscussionTopic] = useMutation(DELETE_DISCUSSION_TOPIC, {
    onCompleted: () => {
      // handle success
    },
    onError: (error) => {
      // handle error
    }
  })

  const handleDelete = () => {
    deleteDiscussionTopic({variables: {id: topicId}})
  }
}

4.3 External Context Access

Global ENV Object (Primary Method)

Type-safe access to server data:

// Access user data
const userId = ENV.current_user_id
const userName = ENV.current_user?.display_name
const userEmail = ENV.current_user?.email

// Access locale/timezone
const timezone = ENV.TIMEZONE          // e.g., "America/Denver"
const locale = ENV.LOCALE              // e.g., "en-US"

// Access context info
const courseId = ENV.course_id
const contextType = ENV.context_type   // "Course", "Account", etc.
const contextAssetString = ENV.context_asset_string  // "course_123"

// Access feature flags
const isFeatureEnabled = ENV.FEATURES?.new_feature_name

// Access permissions
const canEdit = ENV.permissions?.edit_assignments

I18n Access (Scoped Translations)

import {useScope as createI18nScope} from '@canvas/i18n'

const I18n = createI18nScope('assignments')

// Access locale-specific strings
const submitLabel = I18n.t('Submit')
const welcomeMessage = I18n.t('Welcome, %{name}', {name: userName})

// Access current locale
const currentLocale = I18n.locale  // Reads from ENV.LOCALE

Dependency Injection Pattern (Rare)

Some features use dependency injection via props:

// ui/shared/block-editor/react/BlockEditor.tsx
interface BlockEditorProps {
  apiClient?: ApiClient       // Optional, uses default if not provided
  assetHost?: string          // Optional, falls back to ENV
  timezone?: string           // Optional, falls back to ENV.TIMEZONE
}

export function BlockEditor({
  apiClient = defaultApiClient,
  assetHost = ENV.CANVAS_URL,
  timezone = ENV.TIMEZONE
}: BlockEditorProps) {
  // Use injected or default values
}

4.4 HTTP Clients & APIs

doFetchApi (Primary REST Client)

Location: ui/shared/do-fetch-api-effect/index.ts

import doFetchApi from '@canvas/do-fetch-api-effect'

// GET request
const {json, response, link} = await doFetchApi({
  path: '/api/v1/courses/123/assignments',
  method: 'GET',
  params: {per_page: 50, page: 1}
})

// POST request
const {json} = await doFetchApi({
  path: '/api/v1/courses/123/assignments',
  method: 'POST',
  body: {
    name: 'New Assignment',
    points_possible: 100
  }
})

// With pagination
const {link} = await doFetchApi({path: '...'})
const nextPage = link?.next?.url  // Parse Link header

Features:

  • Automatic CSRF token handling
  • JSON encoding/decoding
  • Query parameter serialization
  • Link header parsing for pagination
  • Type-safe with TypeScript

executeQuery (GraphQL Client)

Location: ui/shared/graphql/index.ts

import {executeQuery} from '@canvas/graphql'
import {gql} from 'graphql-tag'

const QUERY = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
    }
  }
`

const result = await executeQuery(QUERY, {id: '123'})

GraphQL Endpoint: /api/graphql

Axios (Legacy)

Location: ui/shared/axios/index.js

import axios from '@canvas/axios'

// Still used in legacy features
const response = await axios.get('/api/v1/courses/123/assignments')

Status: Being phased out in favor of doFetchApi


5. Frontend Architecture Decisions

5.1 Routing

React Router v6 (Client-Side)

Configuration: ui/boot/initializers/router.tsx

import {createBrowserRouter, createRoutesFromElements, Route} from 'react-router-dom'

const portalRouter = createBrowserRouter(
  createRoutesFromElements(
    <Route>
      <Route
        path="/users/:userId/messages"
        lazy={() => import('../../features/messages/react/MessagesRoute')}
      />
      <Route
        path="/courses/:courseId/settings/*"
        lazy={() => import('../../features/course_settings/react/CourseSettingsRoute')}
      />
      {/* More routes... */}
    </Route>
  )
)

// Mount to DOM
export function loadReactRouter() {
  const mountNode = document.querySelector('#react-router-portals')
  if (mountNode) {
    const root = ReactDOM.createRoot(mountNode)
    root.render(
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={portalRouter} />
      </QueryClientProvider>
    )
  }
}

Key Patterns:

  • Lazy loading with lazy() imports for code splitting
  • Nested routes with wildcard paths (/settings/*)
  • Feature-specific routers in some features
  • Conditional routes based on feature flags

Hybrid Routing:

  • Rails routes for server-rendered pages (config/routes.rb)
  • React Router for modern client-side features
  • Page.js still used in some legacy code

5.2 Internationalization (i18n)

i18n-js + i18nliner

Main Library: i18n-js with custom Canvas extensions

Usage Pattern:

import {useScope as createI18nScope} from '@canvas/i18n'

const I18n = createI18nScope('assignments')

// Simple translation
I18n.t('Submit')

// With variables
I18n.t('Welcome, %{name}', {name: userName})

// Pluralization
I18n.t({one: '1 assignment', other: '%{count} assignments'}, {count: 5})

// Number formatting
I18n.n(1234.56)  // "1,234.56" in en-US

// Date formatting
I18n.strftime(date, '%B %d, %Y')

Translation Storage:

  • JavaScript: config/locales/generated/en-js.json
  • Ruby: config/locales/en.yml
  • Package-specific: packages/canvas-rce/locales/en.json

Extraction:

{
  "scripts": {
    "i18n:extract": "node_modules/@instructure/i18nliner-canvas/bin/i18nliner export",
    "i18n:check": "node_modules/@instructure/i18nliner-canvas/bin/i18nliner check"
  }
}

Custom Extensions:

  • @instructure/i18nliner - Extraction tooling
  • @instructure/i18nliner-runtime - Runtime utilities
  • @instructure/i18nliner-canvas - Canvas-specific integration

5.3 Deployment Targets

Bundled in Rails Application

Primary deployment: Assets compiled and served by Rails

// Build output
output: {
  path: join(canvasDir, 'public', webpackPublicPath),
  publicPath: isProduction
    ? '/dist/webpack-production/'
    : '/dist/webpack-dev/',
  filename: '[name]-entry-[contenthash].js',
  chunkFilename: '[name]-chunk-[contenthash].js',
}

Asset Pipeline:

  1. Rspack builds JavaScript bundles
  2. Output to public/dist/webpack-production/
  3. Rails serves assets with long-term caching
  4. Manifest file tracks bundle names

NPM Packages (Select Packages)

Publishable packages:

  • @instructure/canvas-rce → npm registry
  • @instructure/canvas-media → npm registry

Build Output:

// packages/canvas-rce/package.json
{
  "main": "es/index.js",
  "types": "es/index.d.ts",
  "scripts": {
    "build:es": "babel --out-dir es src",
    "build:types": "tsc",
    "publishToNpm": "scripts/publish_to_npm.sh"
  }
}

Module Federation (Planned/Experimental)

Evidence in config:

// ui-build/webpack/webpack.plugins.js
const {ModuleFederationPlugin} = require('@module-federation/enhanced')

// Experimental support for micro-frontends

Status: Present in dependencies but not widely adopted yet

5.4 TypeScript Type Publishing

For Internal Packages

No separate type publishing - types consumed via workspace links:

// ui/shared/block-editor/package.json
{
  "name": "@canvas/block-editor",
  "main": "./react/index.tsx",  // Direct TS source
}

Types resolved directly from source files.

For Published Packages

Dedicated type generation:

// packages/canvas-rce/package.json
{
  "main": "es/index.js",
  "types": "es/index.d.ts",
  "scripts": {
    "build:types": "tsc",
    "build:es": "babel --out-dir es src"
  }
}

Process:

  1. TypeScript compiles .d.ts files
  2. Babel compiles JavaScript
  3. Both published to npm
  4. Consumers get type definitions automatically

6. Development Workflow & Quality

6.1 Local Development Setup

Docker-Based Development

Primary setup: docker-compose.yml

# Start all services
docker compose up

# Run commands in web container
docker compose run --rm web bash

# Inside container:
yarn build:watch        # Watch mode for frontend
bundle exec rails s     # Rails server

Services:

  • web - Rails application
  • postgres - Database
  • redis - Caching
  • jobs - Background jobs

Frontend Development Mode

# Build all assets once
yarn build

# Watch mode (recommended for development)
yarn build:watch

# Just JavaScript (with HMR)
yarn webpack

# Just CSS
yarn build:css:watch

Hot Module Replacement:

  • React Refresh for fast component updates
  • Rspack dev server on port 80 (configurable)
  • WebSocket connection for live reload

Rspack Dev Server

// ui-build/webpack/index.js
devServer: {
  host: '0.0.0.0',
  port: process.env.RSPACK_DEV_SERVER_PORT || 80,
  client: {
    webSocketURL: `ws://canvas-web.${process.env.INST_DOMAIN || 'inst.test'}/ws`
  }
}

6.2 Component Testing

Testing Library Approach

Primary framework: @testing-library/react

// ui/features/assignments/react/__tests__/AssignmentList.test.tsx
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {AssignmentList} from '../AssignmentList'

describe('AssignmentList', () => {
  it('renders assignments', async () => {
    render(<AssignmentList courseId="123" />)

    await waitFor(() => {
      expect(screen.getByText('Math Homework')).toBeInTheDocument()
    })
  })

  it('handles assignment click', async () => {
    const user = userEvent.setup()
    const onSelect = jest.fn()

    render(<AssignmentList courseId="123" onSelect={onSelect} />)

    await user.click(screen.getByText('Math Homework'))
    expect(onSelect).toHaveBeenCalledWith('456')
  })
})

Testing Patterns:

  • Query by accessible text/role (not test IDs)
  • User interactions via userEvent
  • Async queries with waitFor
  • MSW for API mocking (not fetch-mock)

MSW for Network Mocking

Setup: jest/jest-setup.js or test-specific

import {rest} from 'msw'
import {setupServer} from 'msw/node'

const server = setupServer(
  rest.get('/api/v1/courses/:courseId/assignments', (req, res, ctx) => {
    return res(ctx.json([
      {id: '1', name: 'Assignment 1'},
      {id: '2', name: 'Assignment 2'}
    ]))
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

Why MSW:

  • Test actual doFetchApi behavior
  • Realistic network interactions
  • No function mocking required

Test Organization

ui/features/assignments/
├── react/
│   ├── components/
│   │   ├── AssignmentRow.tsx
│   │   └── __tests__/
│   │       └── AssignmentRow.test.tsx
│   ├── AssignmentList.tsx
│   └── __tests__/
│       └── AssignmentList.test.tsx

Test Naming:

  • Unit tests: *.test.tsx or *.spec.tsx
  • Integration tests: *.integration.test.tsx
  • Visual tests: *.visual.test.tsx (rare)

6.3 Preview Environments

No Automated PR Previews

Observation:

  • ❌ No Vercel/Netlify integration
  • ❌ No GitHub Actions deployment
  • ✅ Manual testing via local Docker

Workflow:

  1. Developer runs Canvas locally
  2. Tests changes in browser
  3. Submits PR
  4. CI runs tests
  5. Manual QA before merge

6.4 Code Ownership

Team Metadata in package.json

Pattern: owner field in package.json

// packages/canvas-rce/package.json
{
  "name": "@instructure/canvas-rce",
  "owner": "RCX"  // Rich Content eXperience team
}

// ui/shared/block-editor/package.json
{
  "name": "@canvas/block-editor",
  "owner": "RCX"
}

Teams:

  • RCX - Rich Content Experience
  • (Other teams not identified in sample)

No CODEOWNERS File

Observation:

  • ❌ No .github/CODEOWNERS file
  • ✅ Ownership tracked in package metadata
  • Team assignment likely managed externally (Jira, Confluence)

6.5 Quality Gates

Pre-commit Hooks

{
  "scripts": {
    "postinstall": "yarn dedupe-yarn; patch-package; [ -f ./script/install_hooks ] && ./script/install_hooks || true"
  }
}

Hooks likely include:

  • Linting changed files
  • Running type checks
  • Formatting checks

Lint-Staged

{
  "devDependencies": {
    "lint-staged": "^9"
  },
  "scripts": {
    "lint:staged": "lint-staged"
  }
}

Configuration likely in .lintstagedrc or similar.

CI Checks (Inferred)

Based on scripts:

{
  "scripts": {
    "check": "yarn check:ts && yarn lint --quiet",
    "check:ts": "tsc -p tsconfig.json",
    "check:biome": "yarn biome ci --since=HEAD^ --changed",
    "test:jest:build": "yarn test:jest --maxWorkers=5 --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL"
  }
}

CI likely runs:

  1. TypeScript type checking
  2. ESLint
  3. Biome formatting check
  4. Jest tests (sharded)
  5. Ruby tests (RSpec)

7. Build & Deployment

7.1 Build Tools

Rspack (Webpack Fork)

Configuration: rspack.config.jsui-build/webpack/index.js

module.exports = {
  mode: isProduction ? 'production' : 'development',

  target: ['browserslist'],  // Uses @instructure/browserslist-config-canvas-lms

  entry: {main: resolve(canvasDir, 'ui/index.ts')},

  output: {
    path: join(canvasDir, 'public', webpackPublicPath),
    filename: '[name]-entry-[contenthash].js',
    chunkFilename: '[name]-chunk-[contenthash].js',
    hashFunction: 'xxhash64',
  },

  optimization: {
    moduleIds: isProduction ? 'deterministic' : 'named',
    splitChunks: {
      chunks: 'all',
      maxAsyncRequests: 30,
      maxInitialRequests: 10,
      cacheGroups: {
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'react',
        },
      },
    },
  }
}

Why Rspack:

  • Faster than Webpack (Rust-based)
  • Webpack-compatible configuration
  • Native CSS support (experimental)

Gulp (Legacy Build Tasks)

Used for asset pipeline tasks:

{
  "scripts": {
    "webpack": "gulp rev 1> /dev/null & NODE_ENV=development rspack --watch"
  }
}

Gulp tasks:

  • Asset fingerprinting (gulp rev)
  • Manifest generation
  • Legacy jQuery plugin builds

7.2 Transpilation & Polyfills

SWC (Speedy Web Compiler)

Configuration: ui-build/webpack/webpack.rules.js

const swc = [
  {
    test: /\.(ts|js)$/,
    exclude: /node_modules/,
    use: {
      loader: 'swc-loader',
      options: {
        jsc: {
          parser: {syntax: 'typescript'},
          transform: {
            react: {
              runtime: 'automatic',
              development: isDev,
              refresh: isDev,
            }
          },
          target: 'es2020',
        }
      }
    }
  },
  {
    test: /\.(tsx|jsx)$/,
    exclude: /node_modules/,
    use: {
      loader: 'swc-loader',
      options: {
        jsc: {
          parser: {
            syntax: 'typescript',
            tsx: true,
          },
          transform: {
            react: {
              runtime: 'automatic',
              development: isDev,
              refresh: isDev,
            }
          }
        }
      }
    }
  }
]

Why SWC:

  • 20x faster than Babel
  • React Refresh built-in
  • TypeScript support native
  • Used in both Rspack and Jest

Browserslist Target

Configuration: package.json

{
  "browserslist": [
    "extends @instructure/browserslist-config-canvas-lms"
  ]
}

Package: @instructure/browserslist-config-canvas-lms Defines supported browser versions for transpilation.

7.3 Code Splitting Strategy

Automatic Splitting

optimization: {
  splitChunks: {
    chunks: 'all',              // Split all chunks
    maxAsyncRequests: 30,       // Max parallel downloads
    maxInitialRequests: 10,     // Max initial chunks
    cacheGroups: {
      react: {
        test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
        name: 'react',          // Separate React bundle
        chunks: 'all',
      },
    },
  },
}

Dynamic Imports

Route-level code splitting:

// ui/boot/initializers/router.tsx
<Route
  path="/assignments"
  lazy={() => import('../../features/assignments/react/AssignmentsRoute')}
/>

Component-level lazy loading:

import {lazy, Suspense} from 'react'

const HeavyComponent = lazy(() => import('./HeavyComponent'))

export function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <HeavyComponent />
    </Suspense>
  )
}

7.4 CSS Strategy

Brandable CSS System

Tool: @instructure/brandable_css

{
  "scripts": {
    "build:css": "brandable_css",
    "build:css:watch": "brandable_css --watch",
    "build:css:compressed": "SASS_STYLE=compressed brandable_css"
  }
}

Features:

  • SCSS compilation
  • Theme variables
  • Brand-specific overrides
  • Color scheme support

Not using:

  • ❌ CSS Modules
  • ❌ CSS-in-JS (except InstUI components)
  • ❌ Tailwind
  • ✅ Traditional SCSS with InstUI

7.5 Bundle Analysis

{
  "scripts": {
    "webpack:analyze": "SKIP_SOURCEMAPS=1 WEBPACK_PEDANTIC=0 rspack --analyze"
  },
  "devDependencies": {
    "webpack-bundle-analyzer": "^4.5.0"
  }
}

Generates interactive bundle size visualization.

7.6 Production Optimizations

Minification

Tool: Rspack's built-in minifier (SWC-based)

optimization: {
  minimizer: [minimizeCode],  // Custom minification config
}

Tree Shaking

Enabled by default with ES modules.

Cache Busting

Content hashing in filenames:

output: {
  filename: '[name]-entry-[contenthash].js',
  chunkFilename: '[name]-chunk-[contenthash].js',
}

Manifest Generation

Plugin: rspack-manifest-plugin

// Generates manifest.json mapping logical names to hashed filenames
{
  "main.js": "main-entry-abc123.js",
  "react.js": "react-chunk-def456.js"
}

Rails reads this manifest to insert correct script tags.

7.7 Multi-Environment Build

Development Build

yarn webpack-development  # Single build
yarn webpack              # Watch mode with HMR

Characteristics:

  • Fast source maps (eval-source-map)
  • Named module IDs
  • React Refresh enabled
  • No minification

Production Build

NODE_ENV=production yarn webpack-production

Characteristics:

  • Full source maps (source-map)
  • Deterministic module IDs
  • Code minification
  • Tree shaking
  • Longer build time (~5-10 min)

7.8 Package Build Strategies

Publishable Packages

Example: packages/canvas-rce/

{
  "scripts": {
    "build": "scripts/build-canvas",
    "build:es": "babel --out-dir es src",
    "build:types": "tsc",
    "prepublishOnly": "yarn build && yarn test"
  }
}

Output:

  • es/ - Transpiled JavaScript
  • es/**/*.d.ts - TypeScript definitions
  • Consumed directly from npm

Internal Packages

Example: ui/shared/block-editor/

{
  "main": "./react/index.tsx"  // Direct TypeScript source
}

No build step - consumed as source files via workspace links


Summary & Key Takeaways

Architectural Highlights

  1. Hybrid Monorepo

    • Rails backend + React frontend in single repo
    • ~231 feature apps, ~228 shared packages, ~39 npm packages
    • Yarn Workspaces orchestration (no Turborepo/Nx)
  2. TypeScript-First with Pragmatism

    • Strict mode enabled but allowJs: true
    • Single root tsconfig.json (no per-package configs)
    • any allowed for practical migration
  3. Modern Build Pipeline

    • Rspack (Webpack fork) for fast builds
    • SWC for transpilation (20x faster than Babel)
    • React Refresh for HMR
    • Code splitting with dynamic imports
  4. Component Patterns

    • Function components with hooks
    • InstUI component library (60+ packages at 10.26.2)
    • Components fetch their own data (no centralized data layer)
    • doFetchApi for REST, executeQuery for GraphQL
  5. Testing Strategy

    • Jest primary (SWC transform)
    • Vitest for newer tests
    • @testing-library/react + MSW for realistic tests
    • 10,000ms test timeout
  6. Quality Tooling

    • ESLint (flat config) with React/TypeScript/a11y plugins
    • Biome for formatting (coexisting with ESLint)
    • Pre-commit hooks + lint-staged
    • No CODEOWNERS file (ownership in package.json)
  7. i18n & Routing

    • i18n-js + custom i18nliner for translations
    • React Router v6 for client-side routing
    • GraphQL with codegen for type safety
    • Global ENV object for server data
  8. Data Fetching

    • doFetchApi (fetch wrapper) replacing Axios
    • React Query for server state
    • GraphQL via graphql-request (not Apollo hooks)
    • Components fetch their own data (no containers)

Unique Characteristics

  • Legacy coexistence: Backbone, jQuery still present alongside React
  • Custom build tools: Many Canvas-specific Gulp/Webpack plugins
  • Educational focus: Accessibility and a11y deeply integrated
  • Large scale: One of the largest open-source React apps

Recommendations for New Contributors

  1. Start here:

    • Read CLAUDE.md and ui/CLAUDE.md
    • Run docker compose up
    • Explore ui/features/ for examples
  2. Use these tools:

    • doFetchApi for API calls
    • InstUI components (not custom CSS)
    • @canvas/* imports for shared code
    • useScope for i18n
  3. Avoid these:

    • jQuery, Backbone (legacy only)
    • Axios (being phased out)
    • Class components
    • Direct fetch calls
  4. Follow patterns:

    • Feature-specific routing
    • Component-level data fetching
    • TypeScript for new code
    • @testing-library for tests

End of Report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment