Generated: 2025-11-13 Repository: instructure/canvas-lms Analysis Scope: Frontend architecture, tooling, and development patterns
- Repository Structure & Organization
- Tooling & Development Standards
- Package Management & Dependencies
- Component & API Design Patterns
- Frontend Architecture Decisions
- Development Workflow & Quality
- Build & Deployment
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/
Named with @canvas/ scope via import aliases:
ui/shared/apollo-v3→@canvas/apollo-v3ui/shared/block-editor→@canvas/block-editorui/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"
}Mix of publishable and internal packages:
@instructure/canvas-rce- Rich Content Editor (publishable to npm)@instructure/canvas-media- Media utilities (publishable)canvas-rce/- Companion packagejquery,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"
}Named as private packages with feature-specific naming:
ui/features/assignments/- Assignment managementui/features/gradebook/- Gradebook featureui/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
- 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}
/>
)
}
})- Location:
ui/shared/andpackages/ - 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'Configured in tsconfig.json:
{
"compilerOptions": {
"baseUrl": "./ui",
"paths": {
"@canvas/*": ["shared/*"],
"@instructure/*": ["./packages/*"]
}
}
}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 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/*
Configuration: eslint.config.js (Flat Config format)
Key Plugins:
@eslint/js- Core ESLint rulestypescript-eslint- TypeScript supporteslint-plugin-react- React ruleseslint-plugin-react-hooks- Hooks lintingeslint-plugin-jsx-a11y- Accessibility checkseslint-plugin-import- Import/export validationeslint-plugin-lodash- Lodash best practiceseslint-plugin-promise- Promise handlingeslint-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'- Allowsany(pragmatic)react/jsx-uses-react: 'off'- New JSX transformreact-hooks/rules-of-hooks: 'error'- Strict hooks rulesnotice/notice: 'error'- Copyright header enforcement
Per-Package Overrides: Some packages have custom ESLint configs:
packages/canvas-rce/eslint.config.jsui/features/quiz_log_auditing/eslint.config.js
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 filesyarn biome format --write- Format files- Biome is newer addition, coexisting with ESLint
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
}
}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"
}
}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
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"
}
}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 devKey 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"
}
}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)
Tool: yarn-deduplicate
{
"scripts": {
"dedupe-yarn": "yarn yarn-deduplicate",
"postinstall": "yarn dedupe-yarn; patch-package; ..."
}
}Automatically deduplicate dependencies after install to reduce bundle size.
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
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-uiPattern: 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
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:
- Manual version bump in
package.json - Build and test via
prepublishOnly - Custom publish script (
scripts/publish_to_npm.sh) - No automated changelog generation
Observation: No evidence of Changesets or similar tools:
- ❌ No
.changeset/directory - ❌ No changeset packages in dependencies
- ✅ Manual version management
{
"dependencies": {
"react": "^18",
"react-dom": "^18"
}
}{
"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"
}
}{
"dependencies": {
"@tanstack/react-query": "^5.74.4",
"redux": "^4.0.1",
"zustand": "^4.5.5"
}
}{
"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
}
}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
}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
}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>
)
}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>
}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.jsui/features/wiki_page_index/react/apiClient.jsui/features/permissions/react/apiClient.js
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
})
}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}})
}
}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_assignmentsimport {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.LOCALESome 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
}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 headerFeatures:
- Automatic CSRF token handling
- JSON encoding/decoding
- Query parameter serialization
- Link header parsing for pagination
- Type-safe with TypeScript
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
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
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
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
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:
- Rspack builds JavaScript bundles
- Output to
public/dist/webpack-production/ - Rails serves assets with long-term caching
- Manifest file tracks bundle names
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"
}
}Evidence in config:
// ui-build/webpack/webpack.plugins.js
const {ModuleFederationPlugin} = require('@module-federation/enhanced')
// Experimental support for micro-frontendsStatus: Present in dependencies but not widely adopted yet
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.
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:
- TypeScript compiles
.d.tsfiles - Babel compiles JavaScript
- Both published to npm
- Consumers get type definitions automatically
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 serverServices:
web- Rails applicationpostgres- Databaseredis- Cachingjobs- Background jobs
# 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:watchHot Module Replacement:
- React Refresh for fast component updates
- Rspack dev server on port 80 (configurable)
- WebSocket connection for live reload
// 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`
}
}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)
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
doFetchApibehavior - Realistic network interactions
- No function mocking required
ui/features/assignments/
├── react/
│ ├── components/
│ │ ├── AssignmentRow.tsx
│ │ └── __tests__/
│ │ └── AssignmentRow.test.tsx
│ ├── AssignmentList.tsx
│ └── __tests__/
│ └── AssignmentList.test.tsx
Test Naming:
- Unit tests:
*.test.tsxor*.spec.tsx - Integration tests:
*.integration.test.tsx - Visual tests:
*.visual.test.tsx(rare)
Observation:
- ❌ No Vercel/Netlify integration
- ❌ No GitHub Actions deployment
- ✅ Manual testing via local Docker
Workflow:
- Developer runs Canvas locally
- Tests changes in browser
- Submits PR
- CI runs tests
- Manual QA before merge
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)
Observation:
- ❌ No
.github/CODEOWNERSfile - ✅ Ownership tracked in package metadata
- Team assignment likely managed externally (Jira, Confluence)
{
"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
{
"devDependencies": {
"lint-staged": "^9"
},
"scripts": {
"lint:staged": "lint-staged"
}
}Configuration likely in .lintstagedrc or similar.
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:
- TypeScript type checking
- ESLint
- Biome formatting check
- Jest tests (sharded)
- Ruby tests (RSpec)
Configuration: rspack.config.js → ui-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)
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
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
Configuration: package.json
{
"browserslist": [
"extends @instructure/browserslist-config-canvas-lms"
]
}Package: @instructure/browserslist-config-canvas-lms
Defines supported browser versions for transpilation.
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',
},
},
},
}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>
)
}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
{
"scripts": {
"webpack:analyze": "SKIP_SOURCEMAPS=1 WEBPACK_PEDANTIC=0 rspack --analyze"
},
"devDependencies": {
"webpack-bundle-analyzer": "^4.5.0"
}
}Generates interactive bundle size visualization.
Tool: Rspack's built-in minifier (SWC-based)
optimization: {
minimizer: [minimizeCode], // Custom minification config
}Enabled by default with ES modules.
Content hashing in filenames:
output: {
filename: '[name]-entry-[contenthash].js',
chunkFilename: '[name]-chunk-[contenthash].js',
}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.
yarn webpack-development # Single build
yarn webpack # Watch mode with HMRCharacteristics:
- Fast source maps (
eval-source-map) - Named module IDs
- React Refresh enabled
- No minification
NODE_ENV=production yarn webpack-productionCharacteristics:
- Full source maps (
source-map) - Deterministic module IDs
- Code minification
- Tree shaking
- Longer build time (~5-10 min)
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 JavaScriptes/**/*.d.ts- TypeScript definitions- Consumed directly from npm
Example: ui/shared/block-editor/
{
"main": "./react/index.tsx" // Direct TypeScript source
}No build step - consumed as source files via workspace links
-
Hybrid Monorepo
- Rails backend + React frontend in single repo
- ~231 feature apps, ~228 shared packages, ~39 npm packages
- Yarn Workspaces orchestration (no Turborepo/Nx)
-
TypeScript-First with Pragmatism
- Strict mode enabled but
allowJs: true - Single root
tsconfig.json(no per-package configs) anyallowed for practical migration
- Strict mode enabled but
-
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
-
Component Patterns
- Function components with hooks
- InstUI component library (60+ packages at 10.26.2)
- Components fetch their own data (no centralized data layer)
doFetchApifor REST,executeQueryfor GraphQL
-
Testing Strategy
- Jest primary (SWC transform)
- Vitest for newer tests
- @testing-library/react + MSW for realistic tests
- 10,000ms test timeout
-
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)
-
i18n & Routing
- i18n-js + custom i18nliner for translations
- React Router v6 for client-side routing
- GraphQL with codegen for type safety
- Global
ENVobject for server data
-
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)
- 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
-
Start here:
- Read
CLAUDE.mdandui/CLAUDE.md - Run
docker compose up - Explore
ui/features/for examples
- Read
-
Use these tools:
doFetchApifor API calls- InstUI components (not custom CSS)
@canvas/*imports for shared codeuseScopefor i18n
-
Avoid these:
- jQuery, Backbone (legacy only)
- Axios (being phased out)
- Class components
- Direct fetch calls
-
Follow patterns:
- Feature-specific routing
- Component-level data fetching
- TypeScript for new code
- @testing-library for tests
End of Report