From 84ea012ad5bdcc527ec28eeee64753cf205c6f25 Mon Sep 17 00:00:00 2001 From: tornike14 Date: Fri, 18 Sep 2026 01:54:33 -0400 Subject: [PATCH 01/11] Add category registry, search palette, playback shortcuts, progress tracking, and scaffolds for 17 new topics --- public/icons/ai.svg | 10 + public/icons/backend.svg | 9 + public/icons/frameworks.svg | 5 + src/app/ai/attention/page.tsx | 28 ++ src/app/ai/backpropagation/page.tsx | 28 ++ src/app/ai/embeddings/page.tsx | 28 ++ src/app/ai/next-token-prediction/page.tsx | 28 ++ src/app/ai/page.tsx | 16 + src/app/ai/tokenization/page.tsx | 28 ++ src/app/backend/caching-strategies/page.tsx | 28 ++ src/app/backend/database-indexing/page.tsx | 28 ++ .../backend/http-request-lifecycle/page.tsx | 28 ++ src/app/backend/jwt-authentication/page.tsx | 28 ++ src/app/backend/page.tsx | 16 + src/app/backend/rate-limiting/page.tsx | 28 ++ .../angular-change-detection/page.tsx | 28 ++ src/app/frameworks/page.tsx | 16 + src/app/frameworks/svelte-runes/page.tsx | 28 ++ src/app/frameworks/vue-reactivity/page.tsx | 28 ++ src/app/globals.css | 84 +++++ src/app/javascript/async-await/page.tsx | 28 ++ src/app/javascript/debounce-throttle/page.tsx | 28 ++ src/app/javascript/page.tsx | 39 +-- src/app/layout.tsx | 10 +- src/app/react/concurrent-rendering/page.tsx | 28 ++ src/app/react/page.tsx | 32 +- src/app/react/state-batching/page.tsx | 28 ++ src/app/sitemap.ts | 15 +- src/components/layout/CategoryHero.tsx | 37 +- src/components/layout/CategoryTopicsPage.tsx | 96 +---- src/components/layout/HomeLandingPage.tsx | 257 +++++++++----- src/components/layout/KeyboardHint.tsx | 22 ++ src/components/layout/TopicCard.tsx | 72 ++++ src/components/layout/TopicPagerNav.tsx | 66 ++++ src/components/layout/TopicToggle.tsx | 88 +++-- .../layout/VisualizationPageShell.tsx | 160 +++++---- .../layout/sidebar/CollapsedSidebar.tsx | 3 + .../layout/sidebar/SidebarContent.tsx | 58 +++- src/components/layout/sidebar/TopicLink.tsx | 6 +- src/components/layout/sidebar/utils.ts | 7 +- src/components/progress/ProgressSummary.tsx | 60 ++++ .../progress/TopicProgressContext.tsx | 22 ++ src/components/progress/TopicProgressMark.tsx | 32 ++ src/components/search/CommandPalette.tsx | 257 ++++++++++++++ src/components/search/SearchTrigger.tsx | 46 +++ src/components/search/searchTopics.ts | 51 +++ src/components/theory/TopicTheorySections.tsx | 6 +- .../visualization-ui/HeatmapGrid.tsx | 104 ++++++ .../visualization-ui/MessageFlow.tsx | 113 ++++++ .../visualization-ui/MetricBars.tsx | 86 +++++ .../visualization-ui/PipelineDiagram.tsx | 94 +++++ .../visualization-ui/TokenChips.tsx | 78 +++++ .../TransportControls/StepScrubber.tsx | 41 +++ .../TransportControls/index.tsx | 11 + .../TransportControls/types.ts | 2 + .../angular-change-detection/data.ts | 1 + .../angular-change-detection/helpers.ts | 1 + .../angular-change-detection/index.tsx | 14 + .../angular-change-detection/types.ts | 1 + .../visualizations/async-await/data.ts | 1 + .../visualizations/async-await/helpers.ts | 1 + .../visualizations/async-await/index.tsx | 14 + .../visualizations/async-await/types.ts | 1 + .../visualizations/attention/data.ts | 1 + .../visualizations/attention/helpers.ts | 1 + .../visualizations/attention/index.tsx | 14 + .../visualizations/attention/types.ts | 1 + .../visualizations/backpropagation/data.ts | 1 + .../visualizations/backpropagation/helpers.ts | 1 + .../visualizations/backpropagation/index.tsx | 14 + .../visualizations/backpropagation/types.ts | 1 + .../visualizations/caching-strategies/data.ts | 1 + .../caching-strategies/helpers.ts | 1 + .../caching-strategies/index.tsx | 14 + .../caching-strategies/types.ts | 1 + .../visualizations/closures/index.tsx | 2 + .../concurrent-rendering/data.ts | 1 + .../concurrent-rendering/helpers.ts | 1 + .../concurrent-rendering/index.tsx | 14 + .../concurrent-rendering/types.ts | 1 + .../context-propagation/index.tsx | 2 + .../visualizations/database-indexing/data.ts | 1 + .../database-indexing/helpers.ts | 1 + .../database-indexing/index.tsx | 14 + .../visualizations/database-indexing/types.ts | 1 + .../visualizations/debounce-throttle/data.ts | 1 + .../debounce-throttle/helpers.ts | 1 + .../debounce-throttle/index.tsx | 14 + .../visualizations/debounce-throttle/types.ts | 1 + .../visualizations/destructuring/index.tsx | 2 + .../visualizations/embeddings/data.ts | 1 + .../visualizations/embeddings/helpers.ts | 1 + .../visualizations/embeddings/index.tsx | 14 + .../visualizations/embeddings/types.ts | 1 + .../visualizations/error-boundaries/index.tsx | 2 + .../visualizations/event-delegation/index.tsx | 2 + .../visualizations/event-loop/index.tsx | 2 + .../execution-context/index.tsx | 2 + .../visualizations/fiber-tree/index.tsx | 2 + .../garbage-collection/index.tsx | 2 + .../visualizations/generators/index.tsx | 2 + .../visualizations/heap-stack/index.tsx | 2 + .../visualizations/hoisting/index.tsx | 2 + src/components/visualizations/hooks/index.tsx | 2 + .../http-request-lifecycle/data.ts | 1 + .../http-request-lifecycle/helpers.ts | 1 + .../http-request-lifecycle/index.tsx | 14 + .../http-request-lifecycle/types.ts | 1 + .../visualizations/jwt-authentication/data.ts | 1 + .../jwt-authentication/helpers.ts | 1 + .../jwt-authentication/index.tsx | 14 + .../jwt-authentication/types.ts | 1 + .../visualizations/memoization/index.tsx | 2 + .../visualizations/modules-imports/index.tsx | 2 + .../next-token-prediction/data.ts | 1 + .../next-token-prediction/helpers.ts | 1 + .../next-token-prediction/index.tsx | 14 + .../next-token-prediction/types.ts | 1 + .../visualizations/promises/index.tsx | 2 + .../prototypal-inheritance/index.tsx | 2 + .../visualizations/rate-limiting/data.ts | 1 + .../visualizations/rate-limiting/helpers.ts | 1 + .../visualizations/rate-limiting/index.tsx | 14 + .../visualizations/rate-limiting/types.ts | 1 + .../visualizations/reconciliation/index.tsx | 2 + .../visualizations/reference-value/index.tsx | 2 + .../visualizations/render-cycle/index.tsx | 2 + .../visualizations/scope-chain/index.tsx | 2 + .../server-components/index.tsx | 2 + .../visualizations/spread-rest/index.tsx | 2 + .../visualizations/state-batching/data.ts | 1 + .../visualizations/state-batching/helpers.ts | 1 + .../visualizations/state-batching/index.tsx | 14 + .../visualizations/state-batching/types.ts | 1 + .../visualizations/suspense/index.tsx | 2 + .../visualizations/svelte-runes/data.ts | 1 + .../visualizations/svelte-runes/helpers.ts | 1 + .../visualizations/svelte-runes/index.tsx | 14 + .../visualizations/svelte-runes/types.ts | 1 + .../visualizations/this-keyword/index.tsx | 2 + .../visualizations/tokenization/data.ts | 1 + .../visualizations/tokenization/helpers.ts | 1 + .../visualizations/tokenization/index.tsx | 14 + .../visualizations/tokenization/types.ts | 1 + .../visualizations/type-coercion/index.tsx | 2 + .../use-effect-lifecycle/index.tsx | 2 + .../visualizations/virtual-dom/index.tsx | 2 + .../visualizations/vue-reactivity/data.ts | 1 + .../visualizations/vue-reactivity/helpers.ts | 1 + .../visualizations/vue-reactivity/index.tsx | 14 + .../visualizations/vue-reactivity/types.ts | 1 + src/content/static/storageKeys.ts | 1 + src/content/theory/ai/attention.ts | 11 + src/content/theory/ai/backpropagation.ts | 11 + src/content/theory/ai/embeddings.ts | 11 + .../theory/ai/next-token-prediction.ts | 11 + src/content/theory/ai/tokenization.ts | 11 + .../theory/backend/caching-strategies.ts | 11 + .../theory/backend/database-indexing.ts | 11 + .../theory/backend/http-request-lifecycle.ts | 11 + .../theory/backend/jwt-authentication.ts | 11 + src/content/theory/backend/rate-limiting.ts | 11 + .../frameworks/angular-change-detection.ts | 11 + src/content/theory/frameworks/svelte-runes.ts | 11 + .../theory/frameworks/vue-reactivity.ts | 11 + src/content/theory/index.ts | 34 ++ src/content/theory/javascript/async-await.ts | 11 + .../theory/javascript/debounce-throttle.ts | 11 + .../theory/react/concurrent-rendering.ts | 11 + src/content/theory/react/state-batching.ts | 11 + src/hooks/useStepPlayback.ts | 64 ++++ src/hooks/useTopicProgress.ts | 20 ++ src/lib/categories.ts | 327 ++++++++++++++++++ src/lib/metadata.ts | 229 ++++++++++-- src/lib/progress/topicProgress.ts | 75 ++++ src/lib/topics.ts | 195 ++++++++++- src/types/index.ts | 2 +- 177 files changed, 3679 insertions(+), 403 deletions(-) create mode 100644 public/icons/ai.svg create mode 100644 public/icons/backend.svg create mode 100644 public/icons/frameworks.svg create mode 100644 src/app/ai/attention/page.tsx create mode 100644 src/app/ai/backpropagation/page.tsx create mode 100644 src/app/ai/embeddings/page.tsx create mode 100644 src/app/ai/next-token-prediction/page.tsx create mode 100644 src/app/ai/page.tsx create mode 100644 src/app/ai/tokenization/page.tsx create mode 100644 src/app/backend/caching-strategies/page.tsx create mode 100644 src/app/backend/database-indexing/page.tsx create mode 100644 src/app/backend/http-request-lifecycle/page.tsx create mode 100644 src/app/backend/jwt-authentication/page.tsx create mode 100644 src/app/backend/page.tsx create mode 100644 src/app/backend/rate-limiting/page.tsx create mode 100644 src/app/frameworks/angular-change-detection/page.tsx create mode 100644 src/app/frameworks/page.tsx create mode 100644 src/app/frameworks/svelte-runes/page.tsx create mode 100644 src/app/frameworks/vue-reactivity/page.tsx create mode 100644 src/app/javascript/async-await/page.tsx create mode 100644 src/app/javascript/debounce-throttle/page.tsx create mode 100644 src/app/react/concurrent-rendering/page.tsx create mode 100644 src/app/react/state-batching/page.tsx create mode 100644 src/components/layout/KeyboardHint.tsx create mode 100644 src/components/layout/TopicCard.tsx create mode 100644 src/components/layout/TopicPagerNav.tsx create mode 100644 src/components/progress/ProgressSummary.tsx create mode 100644 src/components/progress/TopicProgressContext.tsx create mode 100644 src/components/progress/TopicProgressMark.tsx create mode 100644 src/components/search/CommandPalette.tsx create mode 100644 src/components/search/SearchTrigger.tsx create mode 100644 src/components/search/searchTopics.ts create mode 100644 src/components/visualization-ui/HeatmapGrid.tsx create mode 100644 src/components/visualization-ui/MessageFlow.tsx create mode 100644 src/components/visualization-ui/MetricBars.tsx create mode 100644 src/components/visualization-ui/PipelineDiagram.tsx create mode 100644 src/components/visualization-ui/TokenChips.tsx create mode 100644 src/components/visualization-ui/TransportControls/StepScrubber.tsx create mode 100644 src/components/visualizations/angular-change-detection/data.ts create mode 100644 src/components/visualizations/angular-change-detection/helpers.ts create mode 100644 src/components/visualizations/angular-change-detection/index.tsx create mode 100644 src/components/visualizations/angular-change-detection/types.ts create mode 100644 src/components/visualizations/async-await/data.ts create mode 100644 src/components/visualizations/async-await/helpers.ts create mode 100644 src/components/visualizations/async-await/index.tsx create mode 100644 src/components/visualizations/async-await/types.ts create mode 100644 src/components/visualizations/attention/data.ts create mode 100644 src/components/visualizations/attention/helpers.ts create mode 100644 src/components/visualizations/attention/index.tsx create mode 100644 src/components/visualizations/attention/types.ts create mode 100644 src/components/visualizations/backpropagation/data.ts create mode 100644 src/components/visualizations/backpropagation/helpers.ts create mode 100644 src/components/visualizations/backpropagation/index.tsx create mode 100644 src/components/visualizations/backpropagation/types.ts create mode 100644 src/components/visualizations/caching-strategies/data.ts create mode 100644 src/components/visualizations/caching-strategies/helpers.ts create mode 100644 src/components/visualizations/caching-strategies/index.tsx create mode 100644 src/components/visualizations/caching-strategies/types.ts create mode 100644 src/components/visualizations/concurrent-rendering/data.ts create mode 100644 src/components/visualizations/concurrent-rendering/helpers.ts create mode 100644 src/components/visualizations/concurrent-rendering/index.tsx create mode 100644 src/components/visualizations/concurrent-rendering/types.ts create mode 100644 src/components/visualizations/database-indexing/data.ts create mode 100644 src/components/visualizations/database-indexing/helpers.ts create mode 100644 src/components/visualizations/database-indexing/index.tsx create mode 100644 src/components/visualizations/database-indexing/types.ts create mode 100644 src/components/visualizations/debounce-throttle/data.ts create mode 100644 src/components/visualizations/debounce-throttle/helpers.ts create mode 100644 src/components/visualizations/debounce-throttle/index.tsx create mode 100644 src/components/visualizations/debounce-throttle/types.ts create mode 100644 src/components/visualizations/embeddings/data.ts create mode 100644 src/components/visualizations/embeddings/helpers.ts create mode 100644 src/components/visualizations/embeddings/index.tsx create mode 100644 src/components/visualizations/embeddings/types.ts create mode 100644 src/components/visualizations/http-request-lifecycle/data.ts create mode 100644 src/components/visualizations/http-request-lifecycle/helpers.ts create mode 100644 src/components/visualizations/http-request-lifecycle/index.tsx create mode 100644 src/components/visualizations/http-request-lifecycle/types.ts create mode 100644 src/components/visualizations/jwt-authentication/data.ts create mode 100644 src/components/visualizations/jwt-authentication/helpers.ts create mode 100644 src/components/visualizations/jwt-authentication/index.tsx create mode 100644 src/components/visualizations/jwt-authentication/types.ts create mode 100644 src/components/visualizations/next-token-prediction/data.ts create mode 100644 src/components/visualizations/next-token-prediction/helpers.ts create mode 100644 src/components/visualizations/next-token-prediction/index.tsx create mode 100644 src/components/visualizations/next-token-prediction/types.ts create mode 100644 src/components/visualizations/rate-limiting/data.ts create mode 100644 src/components/visualizations/rate-limiting/helpers.ts create mode 100644 src/components/visualizations/rate-limiting/index.tsx create mode 100644 src/components/visualizations/rate-limiting/types.ts create mode 100644 src/components/visualizations/state-batching/data.ts create mode 100644 src/components/visualizations/state-batching/helpers.ts create mode 100644 src/components/visualizations/state-batching/index.tsx create mode 100644 src/components/visualizations/state-batching/types.ts create mode 100644 src/components/visualizations/svelte-runes/data.ts create mode 100644 src/components/visualizations/svelte-runes/helpers.ts create mode 100644 src/components/visualizations/svelte-runes/index.tsx create mode 100644 src/components/visualizations/svelte-runes/types.ts create mode 100644 src/components/visualizations/tokenization/data.ts create mode 100644 src/components/visualizations/tokenization/helpers.ts create mode 100644 src/components/visualizations/tokenization/index.tsx create mode 100644 src/components/visualizations/tokenization/types.ts create mode 100644 src/components/visualizations/vue-reactivity/data.ts create mode 100644 src/components/visualizations/vue-reactivity/helpers.ts create mode 100644 src/components/visualizations/vue-reactivity/index.tsx create mode 100644 src/components/visualizations/vue-reactivity/types.ts create mode 100644 src/content/theory/ai/attention.ts create mode 100644 src/content/theory/ai/backpropagation.ts create mode 100644 src/content/theory/ai/embeddings.ts create mode 100644 src/content/theory/ai/next-token-prediction.ts create mode 100644 src/content/theory/ai/tokenization.ts create mode 100644 src/content/theory/backend/caching-strategies.ts create mode 100644 src/content/theory/backend/database-indexing.ts create mode 100644 src/content/theory/backend/http-request-lifecycle.ts create mode 100644 src/content/theory/backend/jwt-authentication.ts create mode 100644 src/content/theory/backend/rate-limiting.ts create mode 100644 src/content/theory/frameworks/angular-change-detection.ts create mode 100644 src/content/theory/frameworks/svelte-runes.ts create mode 100644 src/content/theory/frameworks/vue-reactivity.ts create mode 100644 src/content/theory/javascript/async-await.ts create mode 100644 src/content/theory/javascript/debounce-throttle.ts create mode 100644 src/content/theory/react/concurrent-rendering.ts create mode 100644 src/content/theory/react/state-batching.ts create mode 100644 src/hooks/useTopicProgress.ts create mode 100644 src/lib/categories.ts create mode 100644 src/lib/progress/topicProgress.ts diff --git a/public/icons/ai.svg b/public/icons/ai.svg new file mode 100644 index 0000000..fa80450 --- /dev/null +++ b/public/icons/ai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/icons/backend.svg b/public/icons/backend.svg new file mode 100644 index 0000000..e402776 --- /dev/null +++ b/public/icons/backend.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/frameworks.svg b/public/icons/frameworks.svg new file mode 100644 index 0000000..77802a7 --- /dev/null +++ b/public/icons/frameworks.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/ai/attention/page.tsx b/src/app/ai/attention/page.tsx new file mode 100644 index 0000000..47fc28e --- /dev/null +++ b/src/app/ai/attention/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("attention"); + +const AttentionVisualization = dynamic( + () => + import("@/components/visualizations/attention").then( + (module) => module.Attention, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function AttentionPage() { + return ( + + + + ); +} diff --git a/src/app/ai/backpropagation/page.tsx b/src/app/ai/backpropagation/page.tsx new file mode 100644 index 0000000..2a58475 --- /dev/null +++ b/src/app/ai/backpropagation/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("backpropagation"); + +const BackpropagationVisualization = dynamic( + () => + import("@/components/visualizations/backpropagation").then( + (module) => module.Backpropagation, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function BackpropagationPage() { + return ( + + + + ); +} diff --git a/src/app/ai/embeddings/page.tsx b/src/app/ai/embeddings/page.tsx new file mode 100644 index 0000000..ed36cc7 --- /dev/null +++ b/src/app/ai/embeddings/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("embeddings"); + +const EmbeddingsVisualization = dynamic( + () => + import("@/components/visualizations/embeddings").then( + (module) => module.Embeddings, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function EmbeddingsPage() { + return ( + + + + ); +} diff --git a/src/app/ai/next-token-prediction/page.tsx b/src/app/ai/next-token-prediction/page.tsx new file mode 100644 index 0000000..4aef87b --- /dev/null +++ b/src/app/ai/next-token-prediction/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("next-token-prediction"); + +const NextTokenPredictionVisualization = dynamic( + () => + import("@/components/visualizations/next-token-prediction").then( + (module) => module.NextTokenPrediction, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function NextTokenPredictionPage() { + return ( + + + + ); +} diff --git a/src/app/ai/page.tsx b/src/app/ai/page.tsx new file mode 100644 index 0000000..023227f --- /dev/null +++ b/src/app/ai/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { CategoryTopicsPage } from "@/components/layout/CategoryTopicsPage"; +import { CATEGORIES } from "@/lib/categories"; +import { createCategoryMetadata } from "@/lib/metadata"; +import { getTopicsByCategory } from "@/lib/topics"; + +const category = CATEGORIES.ai; + +export const metadata: Metadata = createCategoryMetadata( + category, + getTopicsByCategory(category.id).length, +); + +export default function CategoryIndexPage() { + return ; +} diff --git a/src/app/ai/tokenization/page.tsx b/src/app/ai/tokenization/page.tsx new file mode 100644 index 0000000..efda94b --- /dev/null +++ b/src/app/ai/tokenization/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("tokenization"); + +const TokenizationVisualization = dynamic( + () => + import("@/components/visualizations/tokenization").then( + (module) => module.Tokenization, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function TokenizationPage() { + return ( + + + + ); +} diff --git a/src/app/backend/caching-strategies/page.tsx b/src/app/backend/caching-strategies/page.tsx new file mode 100644 index 0000000..f70cad2 --- /dev/null +++ b/src/app/backend/caching-strategies/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("caching-strategies"); + +const CachingStrategiesVisualization = dynamic( + () => + import("@/components/visualizations/caching-strategies").then( + (module) => module.CachingStrategies, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function CachingStrategiesPage() { + return ( + + + + ); +} diff --git a/src/app/backend/database-indexing/page.tsx b/src/app/backend/database-indexing/page.tsx new file mode 100644 index 0000000..c713916 --- /dev/null +++ b/src/app/backend/database-indexing/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("database-indexing"); + +const DatabaseIndexingVisualization = dynamic( + () => + import("@/components/visualizations/database-indexing").then( + (module) => module.DatabaseIndexing, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function DatabaseIndexingPage() { + return ( + + + + ); +} diff --git a/src/app/backend/http-request-lifecycle/page.tsx b/src/app/backend/http-request-lifecycle/page.tsx new file mode 100644 index 0000000..6fcbd42 --- /dev/null +++ b/src/app/backend/http-request-lifecycle/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("http-request-lifecycle"); + +const HttpRequestLifecycleVisualization = dynamic( + () => + import("@/components/visualizations/http-request-lifecycle").then( + (module) => module.HttpRequestLifecycle, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function HttpRequestLifecyclePage() { + return ( + + + + ); +} diff --git a/src/app/backend/jwt-authentication/page.tsx b/src/app/backend/jwt-authentication/page.tsx new file mode 100644 index 0000000..e74e9a5 --- /dev/null +++ b/src/app/backend/jwt-authentication/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("jwt-authentication"); + +const JwtAuthenticationVisualization = dynamic( + () => + import("@/components/visualizations/jwt-authentication").then( + (module) => module.JwtAuthentication, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function JwtAuthenticationPage() { + return ( + + + + ); +} diff --git a/src/app/backend/page.tsx b/src/app/backend/page.tsx new file mode 100644 index 0000000..41ce2c9 --- /dev/null +++ b/src/app/backend/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { CategoryTopicsPage } from "@/components/layout/CategoryTopicsPage"; +import { CATEGORIES } from "@/lib/categories"; +import { createCategoryMetadata } from "@/lib/metadata"; +import { getTopicsByCategory } from "@/lib/topics"; + +const category = CATEGORIES.backend; + +export const metadata: Metadata = createCategoryMetadata( + category, + getTopicsByCategory(category.id).length, +); + +export default function CategoryIndexPage() { + return ; +} diff --git a/src/app/backend/rate-limiting/page.tsx b/src/app/backend/rate-limiting/page.tsx new file mode 100644 index 0000000..2ade90a --- /dev/null +++ b/src/app/backend/rate-limiting/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("rate-limiting"); + +const RateLimitingVisualization = dynamic( + () => + import("@/components/visualizations/rate-limiting").then( + (module) => module.RateLimiting, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function RateLimitingPage() { + return ( + + + + ); +} diff --git a/src/app/frameworks/angular-change-detection/page.tsx b/src/app/frameworks/angular-change-detection/page.tsx new file mode 100644 index 0000000..173c6a4 --- /dev/null +++ b/src/app/frameworks/angular-change-detection/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("angular-change-detection"); + +const AngularChangeDetectionVisualization = dynamic( + () => + import("@/components/visualizations/angular-change-detection").then( + (module) => module.AngularChangeDetection, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function AngularChangeDetectionPage() { + return ( + + + + ); +} diff --git a/src/app/frameworks/page.tsx b/src/app/frameworks/page.tsx new file mode 100644 index 0000000..068b73c --- /dev/null +++ b/src/app/frameworks/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { CategoryTopicsPage } from "@/components/layout/CategoryTopicsPage"; +import { CATEGORIES } from "@/lib/categories"; +import { createCategoryMetadata } from "@/lib/metadata"; +import { getTopicsByCategory } from "@/lib/topics"; + +const category = CATEGORIES.frameworks; + +export const metadata: Metadata = createCategoryMetadata( + category, + getTopicsByCategory(category.id).length, +); + +export default function CategoryIndexPage() { + return ; +} diff --git a/src/app/frameworks/svelte-runes/page.tsx b/src/app/frameworks/svelte-runes/page.tsx new file mode 100644 index 0000000..9cdaee9 --- /dev/null +++ b/src/app/frameworks/svelte-runes/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("svelte-runes"); + +const SvelteRunesVisualization = dynamic( + () => + import("@/components/visualizations/svelte-runes").then( + (module) => module.SvelteRunes, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function SvelteRunesPage() { + return ( + + + + ); +} diff --git a/src/app/frameworks/vue-reactivity/page.tsx b/src/app/frameworks/vue-reactivity/page.tsx new file mode 100644 index 0000000..eef62e0 --- /dev/null +++ b/src/app/frameworks/vue-reactivity/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("vue-reactivity"); + +const VueReactivityVisualization = dynamic( + () => + import("@/components/visualizations/vue-reactivity").then( + (module) => module.VueReactivity, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function VueReactivityPage() { + return ( + + + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 3333c27..03ebf3c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -359,6 +359,90 @@ } } + + /* ── Topic cards ── */ + .topic-card { + --card-glow: 244 114 182; + border-color: var(--app-border); + } + + .group:hover .topic-card, + .group:focus-visible .topic-card { + border-color: rgba(var(--card-glow), 0.45); + box-shadow: + 0 14px 32px rgba(2, 6, 23, 0.45), + 0 0 24px rgba(var(--card-glow), 0.16); + transform: translateY(-2px); + } + + /* ── Home hero ── */ + .home-hero { + border: 1px solid rgba(71, 85, 105, 0.5); + background: + radial-gradient(circle at 20% 0%, rgba(251, 191, 36, 0.14), transparent 40%), + radial-gradient(circle at 80% 10%, rgba(34, 211, 238, 0.14), transparent 42%), + radial-gradient(circle at 50% 100%, rgba(244, 114, 182, 0.14), transparent 46%), + linear-gradient(180deg, rgba(12, 23, 44, 0.96), rgba(7, 14, 28, 0.97)); + box-shadow: + 0 20px 36px rgba(2, 6, 23, 0.45), + inset 0 1px 0 rgba(148, 163, 184, 0.12); + } + + .home-hero-grid { + opacity: 0.35; + background-image: + linear-gradient(to right, rgba(148, 163, 184, 0.12) 1px, transparent 1px), + linear-gradient(to bottom, rgba(148, 163, 184, 0.12) 1px, transparent 1px); + background-size: 36px 36px; + mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.9), transparent 75%); + } + + /* ── Step scrubber ── */ + .viz-scrubber input[type="range"] { + --scrubber-fill: 0%; + appearance: none; + width: 7.5rem; + height: 6px; + cursor: pointer; + border-radius: 9999px; + background: + linear-gradient( + 90deg, + rgba(244, 114, 182, 0.85) var(--scrubber-fill), + rgba(51, 65, 85, 0.85) var(--scrubber-fill) + ); + outline: none; + } + + .viz-scrubber input[type="range"]::-webkit-slider-thumb { + appearance: none; + height: 14px; + width: 14px; + border-radius: 9999px; + border: 2px solid #f9a8d4; + background: #0f1a30; + box-shadow: 0 0 10px rgba(244, 114, 182, 0.5); + transition: transform 0.15s ease; + } + + .viz-scrubber input[type="range"]::-moz-range-thumb { + height: 14px; + width: 14px; + border-radius: 9999px; + border: 2px solid #f9a8d4; + background: #0f1a30; + box-shadow: 0 0 10px rgba(244, 114, 182, 0.5); + } + + .viz-scrubber input[type="range"]:hover::-webkit-slider-thumb, + .viz-scrubber input[type="range"]:focus-visible::-webkit-slider-thumb { + transform: scale(1.15); + } + + .viz-scrubber input[type="range"]:focus-visible { + box-shadow: 0 0 0 2px rgba(244, 114, 182, 0.5); + } + /* ── Syntax highlighting token classes ── */ .tok-kw { color: #c084fc; } .tok-builtin { color: #e5c07b; } diff --git a/src/app/javascript/async-await/page.tsx b/src/app/javascript/async-await/page.tsx new file mode 100644 index 0000000..a34d792 --- /dev/null +++ b/src/app/javascript/async-await/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("async-await"); + +const AsyncAwaitVisualization = dynamic( + () => + import("@/components/visualizations/async-await").then( + (module) => module.AsyncAwait, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function AsyncAwaitPage() { + return ( + + + + ); +} diff --git a/src/app/javascript/debounce-throttle/page.tsx b/src/app/javascript/debounce-throttle/page.tsx new file mode 100644 index 0000000..81088e1 --- /dev/null +++ b/src/app/javascript/debounce-throttle/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("debounce-throttle"); + +const DebounceThrottleVisualization = dynamic( + () => + import("@/components/visualizations/debounce-throttle").then( + (module) => module.DebounceThrottle, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function DebounceThrottlePage() { + return ( + + + + ); +} diff --git a/src/app/javascript/page.tsx b/src/app/javascript/page.tsx index 3f0c1a1..fc97e99 100644 --- a/src/app/javascript/page.tsx +++ b/src/app/javascript/page.tsx @@ -1,37 +1,16 @@ import type { Metadata } from "next"; import { CategoryTopicsPage } from "@/components/layout/CategoryTopicsPage"; +import { CATEGORIES } from "@/lib/categories"; +import { createCategoryMetadata } from "@/lib/metadata"; import { getTopicsByCategory } from "@/lib/topics"; -const topicCount = getTopicsByCategory("javascript").length; +const category = CATEGORIES.javascript; -export const metadata: Metadata = { - title: "JavaScript Concepts, Visualized", - description: `Explore ${topicCount} interactive JavaScript visualizations: event loop, closures, hoisting, promises, scope chain, and prototypes. Learn JS internals step by step.`, - keywords: [ - "javascript visualizer", - "javascript visualization", - "javascript concepts", - "javascript fundamentals", - "event loop", - "hoisting", - "closures", - "promises", - "prototype chain", - "this keyword", - "scope chain", - "var vs let vs const", - "garbage collection javascript", - "type coercion", - "execution context", - "heap and stack", - "reference vs value", - "generators iterators", - ], - alternates: { - canonical: "/javascript", - }, -}; +export const metadata: Metadata = createCategoryMetadata( + category, + getTopicsByCategory(category.id).length, +); -export default function JavaScriptIndexPage() { - return ; +export default function CategoryIndexPage() { + return ; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4390771..65c035c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { AppTheme } from "@/components/layout/AppTheme"; import { Sidebar } from "@/components/layout/sidebar"; +import { CommandPalette } from "@/components/search/CommandPalette"; import { CREATOR_LINKEDIN_URL, OPEN_GRAPH_IMAGE_URL, @@ -14,6 +15,7 @@ import { SITE_URL, TWITTER_IMAGE_URL, } from "@/lib/constants"; +import { CATEGORY_LIST } from "@/lib/categories"; import { topics } from "@/lib/topics"; import "./globals.css"; @@ -93,9 +95,12 @@ export default function RootLayout({ const navigationSchema = { "@context": "https://schema.org", "@type": "SiteNavigationElement", - name: ["JavaScript Concepts", ...topics.map((t) => t.title)], + name: [ + ...CATEGORY_LIST.map((c) => c.indexTitle), + ...topics.map((t) => t.title), + ], url: [ - `${SITE_URL}/javascript`, + ...CATEGORY_LIST.map((c) => `${SITE_URL}${c.route}`), ...topics.map((t) => `${SITE_URL}${t.route}`), ], }; @@ -122,6 +127,7 @@ export default function RootLayout({ Skip to main content +
{children} diff --git a/src/app/react/concurrent-rendering/page.tsx b/src/app/react/concurrent-rendering/page.tsx new file mode 100644 index 0000000..2cab383 --- /dev/null +++ b/src/app/react/concurrent-rendering/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("concurrent-rendering"); + +const ConcurrentRenderingVisualization = dynamic( + () => + import("@/components/visualizations/concurrent-rendering").then( + (module) => module.ConcurrentRendering, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function ConcurrentRenderingPage() { + return ( + + + + ); +} diff --git a/src/app/react/page.tsx b/src/app/react/page.tsx index 68db015..dfffe53 100644 --- a/src/app/react/page.tsx +++ b/src/app/react/page.tsx @@ -1,30 +1,16 @@ import type { Metadata } from "next"; import { CategoryTopicsPage } from "@/components/layout/CategoryTopicsPage"; +import { CATEGORIES } from "@/lib/categories"; +import { createCategoryMetadata } from "@/lib/metadata"; import { getTopicsByCategory } from "@/lib/topics"; -const topicCount = getTopicsByCategory("react").length; +const category = CATEGORIES.react; -export const metadata: Metadata = { - title: "React Concepts, Visualized", - description: `Explore ${topicCount} interactive React visualizations: virtual DOM, reconciliation, fiber, hooks, render cycle, and Suspense. Learn React internals step by step.`, - keywords: [ - "react visualizer", - "react visualization", - "react concepts", - "react fundamentals", - "react reconciliation", - "virtual DOM", - "react re-rendering", - "react internals", - "react fiber", - "react hooks", - "react diffing algorithm", - ], - alternates: { - canonical: "/react", - }, -}; +export const metadata: Metadata = createCategoryMetadata( + category, + getTopicsByCategory(category.id).length, +); -export default function ReactIndexPage() { - return ; +export default function CategoryIndexPage() { + return ; } diff --git a/src/app/react/state-batching/page.tsx b/src/app/react/state-batching/page.tsx new file mode 100644 index 0000000..dbad8b6 --- /dev/null +++ b/src/app/react/state-batching/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import dynamic from "next/dynamic"; +import { VisualizationPageShell } from "@/components/layout/VisualizationPageShell"; +import { VisualizationLoading } from "@/components/visualizations/VisualizationLoading"; +import { createTopicMetadata } from "@/lib/metadata"; +import { getTopicOrThrow } from "@/lib/topics"; + +const topic = getTopicOrThrow("state-batching"); + +const StateBatchingVisualization = dynamic( + () => + import("@/components/visualizations/state-batching").then( + (module) => module.StateBatching, + ), + { + loading: () => , + }, +); + +export const metadata: Metadata = createTopicMetadata(topic); + +export default function StateBatchingPage() { + return ( + + + + ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 08e5c38..47b8cc0 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,4 +1,5 @@ import type { MetadataRoute } from "next"; +import { CATEGORY_LIST } from "@/lib/categories"; import { SITE_URL } from "@/lib/constants"; import { topics } from "@/lib/topics"; @@ -12,18 +13,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "weekly", priority: 1, }, - { - url: `${SITE_URL}/javascript`, - lastModified: now, - changeFrequency: "weekly", - priority: 0.9, - }, - { - url: `${SITE_URL}/react`, + ...CATEGORY_LIST.map((category) => ({ + url: `${SITE_URL}${category.route}`, lastModified: now, - changeFrequency: "weekly", + changeFrequency: "weekly" as const, priority: 0.9, - }, + })), ]; const topicRoutes: MetadataRoute.Sitemap = topics.map((topic) => ({ diff --git a/src/components/layout/CategoryHero.tsx b/src/components/layout/CategoryHero.tsx index f86265a..b9fdb12 100644 --- a/src/components/layout/CategoryHero.tsx +++ b/src/components/layout/CategoryHero.tsx @@ -4,32 +4,29 @@ import type { CSSProperties, MouseEvent } from "react"; import { useRef } from "react"; import Image from "next/image"; +import type { CategoryConfig } from "@/lib/categories"; + export type CategoryHeroStyle = CSSProperties & { "--hero-accent": string; "--hero-accent-soft": string; "--hero-secondary": string; }; -export interface CategoryHeroConfig { - iconSrc: string; - iconAlt: string; - iconShellClass: string; - title: string; - highlightedWord: string; - titleTail: string; - description: string; - titleAccentClass: string; - kicker: string; - kickerClass: string; - heroStyle: CategoryHeroStyle; -} - interface CategoryHeroProps { - config: CategoryHeroConfig; + config: CategoryConfig; } -export function CategoryHero({ config }: CategoryHeroProps) { +export const CategoryHero = ({ config }: CategoryHeroProps) => { const sectionRef = useRef(null); + const heroStyle: CategoryHeroStyle = { + "--hero-accent": config.hero.accent, + "--hero-accent-soft": config.hero.accentSoft, + "--hero-secondary": config.hero.secondary, + }; + const [highlightedWord, ...titleRest] = config.indexTitle + .replace(", Visualized", "") + .split(" "); + const titleTail = titleRest.join(" "); const handleMouseMove = (event: MouseEvent) => { const shell = sectionRef.current; @@ -70,7 +67,7 @@ export function CategoryHero({ config }: CategoryHeroProps) {
@@ -108,8 +105,8 @@ export function CategoryHero({ config }: CategoryHeroProps) {

- {config.highlightedWord}{" "} - {config.titleTail} + {highlightedWord}{" "} + {titleTail}

{config.description} @@ -117,4 +114,4 @@ export function CategoryHero({ config }: CategoryHeroProps) {

); -} +}; diff --git a/src/components/layout/CategoryTopicsPage.tsx b/src/components/layout/CategoryTopicsPage.tsx index 51678d6..e6091a3 100644 --- a/src/components/layout/CategoryTopicsPage.tsx +++ b/src/components/layout/CategoryTopicsPage.tsx @@ -1,11 +1,7 @@ -import Link from "next/link"; -import { - CategoryHero, - type CategoryHeroConfig, -} from "@/components/layout/CategoryHero"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { DIFFICULTY_COLORS, SITE_URL } from "@/lib/constants"; +import { CategoryHero } from "@/components/layout/CategoryHero"; +import { TopicCard } from "@/components/layout/TopicCard"; +import { CATEGORIES } from "@/lib/categories"; +import { SITE_URL } from "@/lib/constants"; import { getTopicsByCategory } from "@/lib/topics"; import type { Category } from "@/types"; @@ -13,58 +9,14 @@ interface CategoryTopicsPageProps { category: Category; } -const CATEGORY_CONFIG: Record = { - javascript: { - iconSrc: "/icons/javascript.svg", - iconAlt: "JavaScript logo", - iconShellClass: - "bg-[#f7df1e] p-1.5 shadow-[0_0_28px_rgba(247,223,30,0.46)]", - title: "JavaScript Concepts", - highlightedWord: "JavaScript", - titleTail: "Concepts", - description: - "Core JavaScript internals and runtime behavior, visualized step by step.", - titleAccentClass: - "bg-gradient-to-r from-amber-200 via-yellow-300 to-yellow-500 bg-clip-text text-transparent", - kicker: "Engine Room View", - kickerClass: - "border border-yellow-200/25 bg-yellow-200/10 text-yellow-100/90", - heroStyle: { - "--hero-accent": "247 223 30", - "--hero-accent-soft": "253 230 138", - "--hero-secondary": "56 189 248", - }, - }, - react: { - iconSrc: "/icons/react.svg", - iconAlt: "React logo", - iconShellClass: - "border border-cyan-300/30 bg-[rgba(34,211,238,0.1)] p-1.5 shadow-[0_0_22px_rgba(34,211,238,0.24)]", - title: "React Concepts", - highlightedWord: "React", - titleTail: "Concepts", - description: - "React rendering, reconciliation, and auth flows with interactive diagrams.", - titleAccentClass: - "bg-gradient-to-r from-cyan-200 via-sky-200 to-cyan-400 bg-clip-text text-transparent", - kicker: "Rendering Playground", - kickerClass: "border border-cyan-200/25 bg-cyan-200/10 text-cyan-100/90", - heroStyle: { - "--hero-accent": "34 211 238", - "--hero-accent-soft": "103 232 249", - "--hero-secondary": "192 132 252", - }, - }, -}; - -export function CategoryTopicsPage({ category }: CategoryTopicsPageProps) { +export const CategoryTopicsPage = ({ category }: CategoryTopicsPageProps) => { const topics = getTopicsByCategory(category); - const config = CATEGORY_CONFIG[category]; - const categoryUrl = `${SITE_URL}/${category}`; + const config = CATEGORIES[category]; + const categoryUrl = `${SITE_URL}${config.route}`; const categorySchema = { "@context": "https://schema.org", "@type": "CollectionPage", - name: config.title, + name: config.indexTitle, description: config.description, url: categoryUrl, mainEntity: { @@ -99,30 +51,20 @@ export function CategoryTopicsPage({ category }: CategoryTopicsPageProps) {
-

{config.title}

+
+

+ {config.headingLabel} +

+

+ {topics.length} topics, ordered from fundamentals to internals +

+
- {topics.map((topic) => ( - - - - {topic.title} - - {topic.difficulty} - - - -

- {topic.description} -

-
-
- + {topics.map((topic, index) => ( + ))}
); -} +}; diff --git a/src/components/layout/HomeLandingPage.tsx b/src/components/layout/HomeLandingPage.tsx index 2dd3354..804a0c8 100644 --- a/src/components/layout/HomeLandingPage.tsx +++ b/src/components/layout/HomeLandingPage.tsx @@ -1,42 +1,56 @@ import Link from "next/link"; -import { ArrowRight } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { DIFFICULTY_COLORS, SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/constants"; -import { getTopicsByCategory } from "@/lib/topics"; -import type { Category } from "@/types"; +import Image from "next/image"; +import { ArrowRight, Keyboard, Layers3, BookOpenText } from "lucide-react"; +import { TopicCard } from "@/components/layout/TopicCard"; +import { ProgressSummary } from "@/components/progress/ProgressSummary"; +import { CATEGORY_LIST } from "@/lib/categories"; +import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/constants"; +import { getTopicsByCategory, topics } from "@/lib/topics"; +import { cn } from "@/lib/utils"; -const CATEGORY_SECTIONS: { category: Category; heading: string; blurb: string }[] = [ +const FEATURED_TOPIC_IDS = [ + "event-loop", + "closures", + "reconciliation", + "attention", + "http-request-lifecycle", + "vue-reactivity", +]; + +const HOW_IT_WORKS = [ + { + icon: Layers3, + title: "Watch the state change", + body: "Every step shows the call stack, the tree, the queue, or the tensor as the engine sees it.", + }, { - category: "javascript", - heading: "JavaScript concepts", - blurb: - "Runtime internals you get asked about in interviews and hit in real bugs.", + icon: Keyboard, + title: "Drive it yourself", + body: "Play, pause, scrub to any step, or use the arrow keys. Some topics let you edit the code.", }, { - category: "react", - heading: "React concepts", - blurb: - "How React decides what to render, when to re-render, and what it commits to the DOM.", + icon: BookOpenText, + title: "Then read the theory", + body: "Mechanism, common mistakes, and interview questions live on the same page as the animation.", }, ]; export const HomeLandingPage = () => { - const javascriptTopics = getTopicsByCategory("javascript"); - const reactTopics = getTopicsByCategory("react"); - const allTopics = [...javascriptTopics, ...reactTopics]; + const featured = FEATURED_TOPIC_IDS.map((id) => + topics.find((topic) => topic.id === id), + ).filter((topic): topic is NonNullable => Boolean(topic)); const itemListSchema = { "@context": "https://schema.org", "@type": "CollectionPage", - name: `${SITE_NAME}: JavaScript and React Visualizer`, + name: `${SITE_NAME}: JavaScript, React, Backend, and AI Visualizer`, description: SITE_DESCRIPTION, url: SITE_URL, inLanguage: "en-US", mainEntity: { "@type": "ItemList", - numberOfItems: allTopics.length, - itemListElement: allTopics.map((topic, index) => ({ + numberOfItems: topics.length, + itemListElement: topics.map((topic, index) => ({ "@type": "ListItem", position: index + 1, name: topic.title, @@ -47,83 +61,154 @@ export const HomeLandingPage = () => { }; return ( -
+
" }, + { num: 8, text: "" }, + ], + steps: [ + { + descriptionHtml: + '$state(0) creates a source signal: an object holding the value 0, a version counter, and an empty list of reactions. The compiler rewrites every later read of count to $.get(count) and every write to $.set(count, ...).', + activeLine: 2, + doneLines: [], + signals: [S.count("0")], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + '$derived registers a derived signal but does not run count * 2. Deriveds are lazy: the expression runs on the first read, so until then the node has no value and no dependencies.', + activeLine: 3, + doneLines: [2], + signals: [S.count("0"), S.double("unread", "not computed", [])], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + '$effect registers an effect and schedules it for after the component mounts. The callback has not run yet, so the effect does not know what it depends on.', + activeLine: 4, + doneLines: [2, 3], + signals: [ + S.count("0"), + S.double("unread", "not computed", []), + S.fx("scheduled", []), + ], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + 'The compiler turned the text on line 9 into a template effect. Mounting clones the button and runs the effect: $.get(count) returns 0 and records the template effect as a reaction of count.', + activeLine: 9, + doneLines: [2, 3, 4], + signals: [ + S.count("0"), + S.double("unread", "not computed", []), + S.tpl("running", ["count"]), + S.fx("scheduled", []), + ], + dom: [btn("added"), btnText('""', "added")], + consoleOutput: [], + }, + { + descriptionHtml: + 'The same template effect reads double. The derived is unread, so it pulls now: it runs count * 2, subscribes itself to count, caches 0 and becomes clean. The template effect subscribes to double and writes the text node.', + activeLine: 9, + doneLines: [2, 3, 4], + signals: [ + S.count("0"), + S.double("clean", "0"), + S.tpl("clean", ["count", "double"]), + S.fx("scheduled", []), + ], + dom: [btn(), btnText('"0 x 2 = 0"', "updated")], + consoleOutput: [], + }, + { + descriptionHtml: + 'Effects run in a microtask after the DOM is in place. Reading double inside the callback returns the cached 0 without recomputing and subscribes the effect to double.', + activeLine: 5, + doneLines: [2, 3, 4, 9], + signals: [ + S.count("0"), + S.double("clean", "0"), + S.tpl("clean", ["count", "double"]), + S.fx("running", ["double"]), + ], + dom: [btn(), btnText('"0 x 2 = 0"')], + consoleOutput: ["double is 0"], + }, + { + descriptionHtml: + 'Click. count++ compiles to $.set(count, $.get(count) + 1). The write is the push half of push-pull: it bumps the version, marks double and the template effect dirty, marks $effect maybe-dirty because it only depends on a derived, and queues a flush. Nothing recomputes yet.', + activeLine: 8, + doneLines: [2, 3, 4, 5, 6, 9], + signals: [ + S.count("1"), + S.double("dirty", "0 (stale)"), + S.tpl("dirty", ["count", "double"]), + S.fx("maybe-dirty", ["double"]), + ], + dom: [btn(), btnText('"0 x 2 = 0"')], + consoleOutput: ["double is 0"], + }, + { + descriptionHtml: + 'Flush. Template effects run before user effects. This one re-reads count (1) and pulls double: the derived is dirty, so it recomputes to 2 and bumps its own version. The effect then calls $.set_text on the one text node it owns.', + activeLine: 9, + doneLines: [2, 3, 4, 5, 6, 8], + signals: [ + S.count("1"), + S.double("clean", "2"), + S.tpl("running", ["count", "double"]), + S.fx("maybe-dirty", ["double"]), + ], + dom: [btn(), btnText('"1 x 2 = 2"', "updated")], + consoleOutput: ["double is 0"], + }, + { + descriptionHtml: + '$effect was only maybe-dirty, so the flush checks its dependency first. The version of double changed, so the effect is promoted to dirty and runs. Had double recomputed to an equal value, the effect would have been skipped.', + activeLine: 5, + doneLines: [2, 3, 4, 6, 8, 9], + signals: [ + S.count("1"), + S.double("clean", "2"), + S.tpl("clean", ["count", "double"]), + S.fx("running", ["double"]), + ], + dom: [btn(), btnText('"1 x 2 = 2"')], + consoleOutput: ["double is 0", "double is 2"], + }, + { + descriptionHtml: + "Settled. Every node is clean. No virtual tree was built and nothing was diffed: the write pushed dirty flags along recorded edges, and each reaction pulled fresh values on demand.", + activeLine: null, + doneLines: [2, 3, 4, 5, 6, 8, 9], + signals: [ + S.count("1"), + S.double("clean", "2"), + S.tpl("clean", ["count", "double"]), + S.fx("clean", ["double"]), + ], + dom: [btn(), btnText('"1 x 2 = 2"')], + consoleOutput: ["double is 0", "double is 2"], + }, + ], + }, + { + id: "compiler", + title: "Compiled output", + description: + "The compiler resolves which DOM node depends on which signal at build time, so an update is one set_text call.", + kind: "compiler", + codeLines: [ + { num: 1, text: "" }, + { num: 4, text: "

Counter

" }, + { num: 5, text: "" }, + ], + compiledLines: [ + { num: 1, text: "import * as $ from 'svelte/internal/client';" }, + { num: 2, text: "var root = $.template('

Counter

');" }, + { num: 3, text: "export default function App($$anchor) {" }, + { num: 4, text: " let count = $.state(0);" }, + { num: 5, text: " var fragment = root();" }, + { num: 6, text: " var button = $.sibling($.first_child(fragment), 2);" }, + { num: 7, text: " var text = $.child(button);" }, + { num: 8, text: " button.__click = () => $.update(count);" }, + { num: 9, text: " $.template_effect(() => $.set_text(text, `clicks: ${$.get(count)}`));" }, + { num: 10, text: " $.append($$anchor, fragment);" }, + { num: 11, text: "}" }, + { num: 12, text: "$.delegate(['click']);" }, + ], + steps: [ + { + descriptionHtml: + 'The compiler rewrites $state(0) to $.state(0), which returns a source signal object. count is no longer a plain number, which is why every later read becomes $.get(count).', + activeLine: 2, + doneLines: [], + compiledActiveLine: 4, + compiledDoneLines: [1], + signals: [C.count("0")], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + 'All of the markup becomes one string passed to $.template. The structure is fixed at build time, so Svelte can clone it as real DOM later. The space inside <button> is a placeholder text node for {count}.', + activeLine: 4, + doneLines: [2], + compiledActiveLine: 2, + compiledDoneLines: [1, 4], + signals: [C.count("0")], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + 'root() clones the template with cloneNode(true). This produces real DOM nodes immediately, not a description of DOM to diff later.', + activeLine: 5, + doneLines: [2, 4], + compiledActiveLine: 5, + compiledDoneLines: [1, 2, 4], + signals: [C.count("0")], + dom: [h1("added"), btn("added"), btnText('" "', "added")], + consoleOutput: [], + }, + { + descriptionHtml: + '$.sibling and $.child walk to the text node once and keep a reference. The compiler knew at build time that only this node depends on count, so there is nothing to search for on update.', + activeLine: 6, + doneLines: [2, 4, 5], + compiledActiveLine: 7, + compiledDoneLines: [1, 2, 4, 5, 6], + signals: [C.count("0")], + dom: [h1(), btn(), btnText('" "', "active")], + consoleOutput: [], + }, + { + descriptionHtml: + 'onclick={() => count++} becomes a property assignment, button.__click. $.delegate([\'click\']) on the last line installs one root listener that walks up from the event target looking for __click, so no listener is attached per element.', + activeLine: 5, + doneLines: [2, 4, 6], + compiledActiveLine: 8, + compiledDoneLines: [1, 2, 4, 5, 6, 7], + signals: [C.count("0")], + dom: [h1(), btn("active"), btnText('" "')], + consoleOutput: [], + }, + { + descriptionHtml: + '$.template_effect wraps the {count} expression and runs it right away. $.get(count) returns 0 and records this template effect as a reaction of count. $.set_text writes text.nodeValue.', + activeLine: 6, + doneLines: [2, 4, 5], + compiledActiveLine: 9, + compiledDoneLines: [1, 2, 4, 5, 6, 7, 8], + signals: [C.count("0"), C.tpl("running")], + dom: [h1(), btn(), btnText('"clicks: 0"', "updated")], + consoleOutput: [], + }, + { + descriptionHtml: + "$.append inserts the fragment at the mount point. Setup is done: one signal, one template effect, and one recorded edge between them.", + activeLine: 7, + doneLines: [2, 4, 5, 6], + compiledActiveLine: 10, + compiledDoneLines: [1, 2, 4, 5, 6, 7, 8, 9], + signals: [C.count("0"), C.tpl("clean")], + dom: [h1(), btn(), btnText('"clicks: 0"')], + consoleOutput: [], + }, + { + descriptionHtml: + 'Click. The delegated listener finds __click and calls $.update(count), shorthand for $.set(count, $.get(count) + 1). The write bumps the version, marks the template effect dirty and queues a microtask flush. The DOM is untouched so far.', + activeLine: 5, + doneLines: [2, 4, 6, 7], + compiledActiveLine: 8, + compiledDoneLines: [1, 2, 4, 5, 6, 7, 9, 10, 12], + signals: [C.count("1"), C.tpl("dirty")], + dom: [h1(), btn(), btnText('"clicks: 0"')], + consoleOutput: [], + }, + { + descriptionHtml: + "Flush. The template effect re-runs its closure: $.get(count) now returns 1 and $.set_text assigns the new string to the same text node. One property write on one node.", + activeLine: 6, + doneLines: [2, 4, 5, 7], + compiledActiveLine: 9, + compiledDoneLines: [1, 2, 4, 5, 6, 7, 8, 10, 12], + signals: [C.count("1"), C.tpl("running")], + dom: [h1(), btn(), btnText('"clicks: 1"', "updated")], + consoleOutput: [], + }, + { + descriptionHtml: + "Settled. The <h1> and the <button> were never revisited because no effect reads them. There was no virtual tree to build and no diff to walk: the compiler resolved which node depends on which signal before the code ever ran.", + activeLine: null, + doneLines: [2, 4, 5, 6, 7], + compiledActiveLine: null, + compiledDoneLines: [1, 2, 4, 5, 6, 7, 8, 9, 10, 12], + signals: [C.count("1"), C.tpl("clean")], + dom: [h1(), btn(), btnText('"clicks: 1"')], + consoleOutput: [], + }, + ], + }, + { + id: "deep", + title: "Deep state and proxies", + description: + "$state proxies objects and arrays so nested writes are tracked. $state.raw opts out and only reacts to reassignment.", + kind: "deep", + codeLines: [ + { num: 1, text: "" }, + { num: 12, text: "{#each todos as todo}" }, + { num: 13, text: "
  • {todo.text} {todo.done ? 'done' : 'open'}
  • " }, + { num: 14, text: "{/each}" }, + { num: 15, text: "

    raw: {raw.n}

    " }, + ], + steps: [ + { + descriptionHtml: + '$state receives an array, so it wraps it in a Proxy and stores the proxy in the todos source. No per-property signals exist yet; the proxy creates one for each property the first time something reads it.', + activeLine: 2, + doneLines: [], + signals: [D.todos()], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + "$state.raw creates a source that holds the object as-is, with no proxy. Reassigning raw is tracked; writing raw.n is not, because there is no trap to observe it.", + activeLine: 3, + doneLines: [2], + signals: [D.todos(), D.raw("{ n: 0 }")], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + '$effect is registered and scheduled for after mount. Its dependencies will be whatever it reads when it runs.', + activeLine: 4, + doneLines: [2, 3], + signals: [D.todos(), D.raw("{ n: 0 }"), D.fx("scheduled", [])], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + 'Mount. The each block is a template effect. It reads todos, then length through the proxy get trap, which creates the todos.length source on demand and subscribes the block to it.', + activeLine: 12, + doneLines: [2, 3, 4], + signals: [ + D.todos(), + D.len("1"), + D.raw("{ n: 0 }"), + D.each("running"), + D.fx("scheduled", []), + ], + dom: [], + consoleOutput: [], + }, + { + descriptionHtml: + 'The block reads todos[0], which creates an index source and wraps the nested object in its own proxy. The <li> template effect reads done, creating todos[0].done and subscribing to it.', + activeLine: 13, + doneLines: [2, 3, 4, 12], + signals: [ + D.todos(), + D.len("1"), + D.i0(), + D.i0done("false"), + D.raw("{ n: 0 }"), + D.each("clean"), + D.li0("running"), + D.fx("scheduled", []), + ], + dom: [li("li0", '"write open"', "added")], + consoleOutput: [], + }, + { + descriptionHtml: + 'The <p> effect reads raw, so it subscribes to that source. The following .n read is a plain property access on a plain object, and the graph does not see it.', + activeLine: 15, + doneLines: [2, 3, 4, 12, 13, 14], + signals: [ + D.todos(), + D.len("1"), + D.i0(), + D.i0done("false"), + D.raw("{ n: 0 }"), + D.each("clean"), + D.li0("clean"), + D.p("running"), + D.fx("scheduled", []), + ], + dom: [li("li0", '"write open"'), pRaw('"raw: 0"', "added")], + consoleOutput: [], + }, + { + descriptionHtml: + 'The effect runs in a microtask after the DOM is ready. filter reads length and each item\'s done, so the effect subscribes to every source it touched.', + activeLine: 5, + doneLines: [2, 3, 4, 12, 13, 14, 15], + signals: [ + D.todos(), + D.len("1"), + D.i0(), + D.i0done("false"), + D.raw("{ n: 0 }"), + D.each("clean"), + D.li0("clean"), + D.p("clean"), + D.fx("running", ["todos.length", "todos[0].done"]), + ], + dom: [li("li0", '"write open"'), pRaw('"raw: 0"')], + consoleOutput: ["open: 1"], + }, + { + descriptionHtml: + 'push goes through the proxy\'s set trap twice: it writes index 1, creating a todos[1] source, then writes length to 2. The reactions of length, the each block and $effect, are marked dirty and a flush is queued. li #0 is untouched.', + activeLine: 7, + doneLines: [2, 3, 4, 5, 6, 12, 13, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("false"), + D.i1(), + D.raw("{ n: 0 }"), + D.each("dirty"), + D.li0("clean"), + D.p("clean"), + D.fx("dirty", ["todos.length", "todos[0].done"]), + ], + dom: [li("li0", '"write open"'), pRaw('"raw: 0"')], + consoleOutput: ["open: 1"], + }, + { + descriptionHtml: + "Flush. The each block sees length 2 and mounts one more <li> with its own template effect, which reads and creates todos[1].done. The first <li> keeps its DOM nodes.", + activeLine: 13, + doneLines: [2, 3, 4, 5, 6, 7, 12, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("false"), + D.i1(), + D.i1done("false"), + D.raw("{ n: 0 }"), + D.each("running"), + D.li0("clean"), + D.li1("clean"), + D.p("clean"), + D.fx("dirty", ["todos.length", "todos[0].done"]), + ], + dom: [ + li("li0", '"write open"'), + li("li1", '"ship open"', "added"), + pRaw('"raw: 0"'), + ], + consoleOutput: ["open: 1"], + }, + { + descriptionHtml: + "The effect re-runs and now also reads todos[1].done, so its dependency list grows. Dependencies are collected fresh on every run, never declared up front.", + activeLine: 5, + doneLines: [2, 3, 4, 7, 12, 13, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("false"), + D.i1(), + D.i1done("false"), + D.raw("{ n: 0 }"), + D.each("clean"), + D.li0("clean"), + D.li1("clean"), + D.p("clean"), + D.fx("running", ["todos.length", "todos[0].done", "todos[1].done"]), + ], + dom: [li("li0", '"write open"'), li("li1", '"ship open"'), pRaw('"raw: 0"')], + consoleOutput: ["open: 1", "open: 2"], + }, + { + descriptionHtml: + 'todos[0].done = true hits the nested proxy\'s set trap and writes the todos[0].done source. Only its reactions, li #0 and $effect, are marked dirty. The each block and li #1 depend on other sources and stay clean.', + activeLine: 8, + doneLines: [2, 3, 4, 5, 6, 7, 12, 13, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("true"), + D.i1(), + D.i1done("false"), + D.raw("{ n: 0 }"), + D.each("clean"), + D.li0("dirty"), + D.li1("clean"), + D.p("clean"), + D.fx("dirty", ["todos.length", "todos[0].done", "todos[1].done"]), + ], + dom: [li("li0", '"write open"'), li("li1", '"ship open"'), pRaw('"raw: 0"')], + consoleOutput: ["open: 1", "open: 2"], + }, + { + descriptionHtml: + "Flush. The li #0 template effect re-evaluates its expression and calls $.set_text on its own text node. No list diff ran; the compiler tied this node to todos[0].done when it created it.", + activeLine: 13, + doneLines: [2, 3, 4, 5, 6, 7, 8, 12, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("true"), + D.i1(), + D.i1done("false"), + D.raw("{ n: 0 }"), + D.each("clean"), + D.li0("running"), + D.li1("clean"), + D.p("clean"), + D.fx("dirty", ["todos.length", "todos[0].done", "todos[1].done"]), + ], + dom: [li("li0", '"write done"', "updated"), li("li1", '"ship open"'), pRaw('"raw: 0"')], + consoleOutput: ["open: 1", "open: 2"], + }, + { + descriptionHtml: + "The effect runs after the DOM update and logs the new count. Both consumers of todos[0].done ran exactly once for a single nested write.", + activeLine: 5, + doneLines: [2, 3, 4, 7, 8, 12, 13, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("true"), + D.i1(), + D.i1done("false"), + D.raw("{ n: 0 }"), + D.each("clean"), + D.li0("clean"), + D.li1("clean"), + D.p("clean"), + D.fx("running", ["todos.length", "todos[0].done", "todos[1].done"]), + ], + dom: [li("li0", '"write done"'), li("li1", '"ship open"'), pRaw('"raw: 0"')], + consoleOutput: ["open: 1", "open: 2", "open: 1"], + }, + { + descriptionHtml: + "raw.n = 1 is an ordinary property write on an ordinary object. No trap runs, no source is written, nothing is scheduled, and the <p> still shows 0. This is the trade: $state.raw skips proxy overhead for large or immutable data at the cost of tracking only reassignment.", + activeLine: 9, + doneLines: [2, 3, 4, 5, 6, 7, 8, 12, 13, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("true"), + D.i1(), + D.i1done("false"), + D.raw("{ n: 1 }", "write not tracked"), + D.each("clean"), + D.li0("clean"), + D.li1("clean"), + D.p("clean"), + D.fx("clean", ["todos.length", "todos[0].done", "todos[1].done"]), + ], + dom: [li("li0", '"write done"'), li("li1", '"ship open"'), pRaw('"raw: 0"')], + consoleOutput: ["open: 1", "open: 2", "open: 1"], + }, + { + descriptionHtml: + "raw = { n: raw.n } is a reassignment, so the compiler emitted $.set(raw, ...). That writes the source, marks the <p> effect dirty, and the flush sets the text to 1.", + activeLine: 10, + doneLines: [2, 3, 4, 5, 6, 7, 8, 9, 12, 13, 14, 15], + signals: [ + D.todos(), + D.len("2"), + D.i0(), + D.i0done("true"), + D.i1(), + D.i1done("false"), + D.raw("{ n: 1 }", "reassigned"), + D.each("clean"), + D.li0("clean"), + D.li1("clean"), + D.p("running"), + D.fx("clean", ["todos.length", "todos[0].done", "todos[1].done"]), + ], + dom: [li("li0", '"write done"'), li("li1", '"ship open"'), pRaw('"raw: 1"', "updated")], + consoleOutput: ["open: 1", "open: 2", "open: 1"], + }, + ], + }, +]; diff --git a/src/components/visualizations/svelte-runes/helpers.ts b/src/components/visualizations/svelte-runes/helpers.ts index cb0ff5c..c1dc939 100644 --- a/src/components/visualizations/svelte-runes/helpers.ts +++ b/src/components/visualizations/svelte-runes/helpers.ts @@ -1 +1,69 @@ -export {}; +import { + createKindBadgeClass, + createKindLabel, +} from "@/lib/visualization-helpers"; +import type { + DomNodeState, + SignalNodeType, + SignalStatus, + SvelteRunesKind, +} from "./types"; + +export const kindBadgeClass = createKindBadgeClass({ + signals: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25", + compiler: "bg-violet-500/15 text-violet-400 border-violet-500/25", + deep: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25", +}); + +export const kindLabel = createKindLabel({ + signals: "signal graph", + compiler: "compiled output", + deep: "deep state", +}); + +export const SIGNAL_TYPE_LABELS: Record = { + source: "src", + derived: "drv", + template: "tpl", + effect: "fx", +}; + +export const SIGNAL_TYPE_STYLES: Record = { + source: "bg-cyan-500/15 text-cyan-300 border-cyan-500/30", + derived: "bg-violet-500/15 text-violet-300 border-violet-500/30", + template: "bg-amber-500/15 text-amber-300 border-amber-500/30", + effect: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", +}; + +export const SIGNAL_STATUS_STYLES: Record = { + clean: "bg-slate-500/10 text-slate-400 border-slate-500/25", + unread: "bg-slate-500/10 text-slate-400 border-dashed border-slate-500/40", + dirty: "bg-rose-500/15 text-rose-300 border-rose-500/30", + "maybe-dirty": "bg-amber-500/15 text-amber-300 border-amber-500/30", + scheduled: "bg-amber-500/15 text-amber-300 border-amber-500/30", + running: "bg-cyan-500/15 text-cyan-200 border-cyan-400/40", +}; + +export const SIGNAL_ROW_STYLES: Record = { + clean: "border-slate-500/30 bg-slate-800/30", + unread: "border-slate-500/30 bg-slate-800/20", + dirty: "border-rose-400/40 bg-rose-400/8", + "maybe-dirty": "border-amber-400/35 bg-amber-400/8", + scheduled: "border-amber-400/35 bg-amber-400/8", + running: + "border-cyan-300/50 bg-cyan-400/10 shadow-[0_0_16px_rgba(34,211,238,0.14)]", +}; + +export const DOM_STATE_STYLES: Record = { + unchanged: "border-slate-500/30 bg-slate-800/30 text-slate-400", + added: "border-emerald-400/40 bg-emerald-400/8 text-emerald-200", + updated: "border-amber-400/40 bg-amber-400/10 text-amber-200", + active: "border-cyan-300/50 bg-cyan-400/10 text-cyan-100", +}; + +export const DOM_STATE_LABELS: Record = { + unchanged: null, + added: "added", + updated: "set_text", + active: "ref", +}; diff --git a/src/components/visualizations/svelte-runes/index.tsx b/src/components/visualizations/svelte-runes/index.tsx index 2e84eec..1476268 100644 --- a/src/components/visualizations/svelte-runes/index.tsx +++ b/src/components/visualizations/svelte-runes/index.tsx @@ -1,14 +1,217 @@ "use client"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { + CodeBlock, + type CodeBlockLine, +} from "@/components/visualization-ui/CodeBlock"; +import { ConsoleOutput } from "@/components/visualization-ui/ConsoleOutput"; +import { TransportControls } from "@/components/visualization-ui/TransportControls"; import { ExampleSelector } from "@/components/visualization-ui/ExampleSelector"; +import { ToolbarPortal } from "@/components/layout/ToolbarPortal"; +import { cn } from "@/lib/utils"; +import { + VISUALIZATION_PANEL_TITLES, + VISUALIZATION_EMPTY_STATES, +} from "@/lib/visualization/uiCopy"; +import { useStepPlayback } from "@/hooks/useStepPlayback"; +import { useChangeFlash } from "@/hooks/useChangeFlash"; +import { EXAMPLES } from "./data"; +import { kindBadgeClass, kindLabel } from "./helpers"; +import { SignalGraphPanel } from "./components/SignalGraphPanel"; +import { DomPanel } from "./components/DomPanel"; +import { CompiledCodePanel } from "./components/CompiledCodePanel"; + +const waiting = ( +

    + waiting +

    +); -// STUB: replaced by the real implementation. Keeps the build green while -// topics are authored in parallel. export const SvelteRunes = () => { - void ExampleSelector; + const [activeExampleId, setActiveExampleId] = useState(EXAMPLES[0].id); + + const handleExampleChange = (id: string) => { + setActiveExampleId(id); + }; + + const example = + EXAMPLES.find((e) => e.id === activeExampleId) ?? EXAMPLES[0]; + + const { + currentStepIndex, + isPlaying, + speedLevel, + speedLabel, + canStep, + canStepBack, + togglePlay, + step: handleStep, + stepBack: handleStepBack, + reset: handleReset, + setSpeedLevel, + jumpTo, + } = useStepPlayback({ + totalSteps: example.steps.length, + initialStep: -1, + resetKey: activeExampleId, + }); + + const currentStep = + currentStepIndex >= 0 ? example.steps[currentStepIndex] : null; + + const showConsole = example.steps.some((s) => s.consoleOutput.length > 0); + + const flashes = useChangeFlash( + { + description: currentStep?.descriptionHtml, + signals: currentStep?.signals, + dom: currentStep?.dom, + console: currentStep?.consoleOutput, + compiled: currentStep?.compiledActiveLine, + }, + currentStepIndex, + ); + return ( -

    - Svelte Runes visualization coming soon. -

    + <> + +
    +
    +
    + ( + + {kindLabel(ex.kind)} + + )} + /> + + {kindLabel(example.kind)} + +
    + + +
    + +
    + {currentStep?.descriptionHtml ? ( +

    + ) : ( +

    + {VISUALIZATION_EMPTY_STATES.stepDescription} +

    + )} +
    +
    +
    + +
    +
    + + { + const isActive = currentStep?.activeLine === line.num; + const isDone = + currentStep?.doneLines.includes(line.num) ?? false; + return { + key: line.num, + lineNumber: line.num, + text: line.text, + className: cn( + isActive && "is-active", + isDone && !isActive && "is-done", + ), + }; + })} + /> + + +
    + {example.compiledLines && ( + + )} + +
    + + {currentStep ? ( + + ) : ( + waiting + )} + + + + {currentStep ? : waiting} + +
    + + {showConsole && ( +
    + +
    + )} +
    +
    +
    + ); }; diff --git a/src/components/visualizations/svelte-runes/types.ts b/src/components/visualizations/svelte-runes/types.ts index cb0ff5c..b85b838 100644 --- a/src/components/visualizations/svelte-runes/types.ts +++ b/src/components/visualizations/svelte-runes/types.ts @@ -1 +1,59 @@ -export {}; +import type { SourceLine } from "@/types/visualization"; +import type { ExampleOption } from "@/components/visualization-ui/ExampleSelector"; + +export type SvelteRunesKind = "signals" | "compiler" | "deep"; + +/** Node kinds in the reactive graph. Template effects are the compiler's render effects. */ +export type SignalNodeType = "source" | "derived" | "template" | "effect"; + +/** + * Status flags modelled on the Svelte 5 internals: + * unread is a derived that has never been pulled, maybe-dirty is a reaction + * whose only changed dependency is a derived that may recompute to an equal value. + */ +export type SignalStatus = + | "clean" + | "unread" + | "dirty" + | "maybe-dirty" + | "scheduled" + | "running"; + +export interface SignalNode { + id: string; + name: string; + type: SignalNodeType; + status: SignalStatus; + value?: string; + /** Names of the signals this node read on its last run. */ + deps: string[]; + note?: string; +} + +export type DomNodeState = "unchanged" | "added" | "updated" | "active"; + +export interface DomNode { + id: string; + label: string; + text?: string; + state: DomNodeState; + depth?: number; +} + +export interface SvelteRunesStep { + descriptionHtml: string; + activeLine: number | null; + doneLines: number[]; + signals: SignalNode[]; + dom: DomNode[]; + consoleOutput: string[]; + compiledActiveLine?: number | null; + compiledDoneLines?: number[]; +} + +export interface SvelteRunesExample extends ExampleOption { + kind: SvelteRunesKind; + codeLines: SourceLine[]; + compiledLines?: SourceLine[]; + steps: SvelteRunesStep[]; +} diff --git a/src/components/visualizations/tokenization/components/SegmentGroups.tsx b/src/components/visualizations/tokenization/components/SegmentGroups.tsx new file mode 100644 index 0000000..5b67f6a --- /dev/null +++ b/src/components/visualizations/tokenization/components/SegmentGroups.tsx @@ -0,0 +1,36 @@ +import { TokenChips } from "@/components/visualization-ui/TokenChips"; +import type { SegmentGroup } from "../types"; + +interface SegmentGroupsProps { + groups: SegmentGroup[]; +} + +export const SegmentGroups = ({ groups }: SegmentGroupsProps) => { + if (groups.length === 0) { + return ( +

    + waiting +

    + ); + } + + return ( +
    + {groups.map((group) => ( +
    + + {group.label} + + +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/tokenization/components/StatePanels.tsx b/src/components/visualizations/tokenization/components/StatePanels.tsx new file mode 100644 index 0000000..e5bde73 --- /dev/null +++ b/src/components/visualizations/tokenization/components/StatePanels.tsx @@ -0,0 +1,83 @@ +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { TokenChips } from "@/components/visualization-ui/TokenChips"; +import { MetricBars } from "@/components/visualization-ui/MetricBars"; +import { panelTitles } from "../helpers"; +import type { TokenizationKind, TokenizationStep } from "../types"; +import { SegmentGroups } from "./SegmentGroups"; +import { StatsGrid } from "./StatsGrid"; + +interface StatePanelsProps { + kind: TokenizationKind; + step: TokenizationStep | null; + flashes: { + segments: boolean; + rules: boolean; + aux: boolean; + stats: boolean; + }; +} + +const Waiting = () => ( +

    + waiting +

    +); + +export const StatePanels = ({ kind, step, flashes }: StatePanelsProps) => { + const titles = panelTitles(kind); + const isTraining = kind === "bpe"; + + return ( +
    + + + + +
    + + {step ? ( + + ) : ( + + )} + + + + {!step ? ( + + ) : isTraining ? ( + + ) : ( + + )} + +
    + + + + +
    + ); +}; diff --git a/src/components/visualizations/tokenization/components/StatsGrid.tsx b/src/components/visualizations/tokenization/components/StatsGrid.tsx new file mode 100644 index 0000000..42fbb4d --- /dev/null +++ b/src/components/visualizations/tokenization/components/StatsGrid.tsx @@ -0,0 +1,38 @@ +import { cn } from "@/lib/utils"; +import { STAT_TONE_STYLES } from "../helpers"; +import type { StatItem } from "../types"; + +interface StatsGridProps { + stats: StatItem[]; +} + +export const StatsGrid = ({ stats }: StatsGridProps) => { + if (stats.length === 0) { + return ( +

    + waiting +

    + ); + } + + return ( +
      + {stats.map((item) => ( +
    • + + {item.label} + + + {item.value} + +
    • + ))} +
    + ); +}; diff --git a/src/components/visualizations/tokenization/data.ts b/src/components/visualizations/tokenization/data.ts index cb0ff5c..857915e 100644 --- a/src/components/visualizations/tokenization/data.ts +++ b/src/components/visualizations/tokenization/data.ts @@ -1 +1,960 @@ -export {}; +import type { TokenChip, TokenChipTone } from "@/components/visualization-ui/TokenChips"; +import type { MetricBar } from "@/components/visualization-ui/MetricBars"; +import type { + SegmentGroup, + StatItem, + StatTone, + TokenizationExample, + TokenizationStep, +} from "./types"; + +/* ------------------------------------------------------------------ */ +/* Builders */ +/* ------------------------------------------------------------------ */ + +type ChipSpec = string | [label: string, value?: string, tone?: TokenChipTone]; + +const chips = (prefix: string, specs: ChipSpec[]): TokenChip[] => + specs.map((spec, index) => { + const label = typeof spec === "string" ? spec : spec[0]; + const value = typeof spec === "string" ? undefined : spec[1]; + const tone = typeof spec === "string" ? undefined : spec[2]; + return { + id: `${prefix}-${index}-${label}-${tone ?? "n"}`, + label, + value, + tone, + }; + }); + +const group = ( + id: string, + label: string, + specs: ChipSpec[], + showIndex = false, +): SegmentGroup => ({ id, label, chips: chips(id, specs), showIndex }); + +const stat = ( + id: string, + label: string, + value: string, + tone?: StatTone, +): StatItem => ({ id, label, value, tone }); + +/** Visible stand-in for the space byte inside a token. */ +const SP = "␣"; + +/* ------------------------------------------------------------------ */ +/* Example 1: BPE training */ +/* ------------------------------------------------------------------ */ + +const CORPUS: [word: string, freq: number][] = [ + ["low", 5], + ["lower", 2], + ["newest", 6], + ["widest", 3], +]; + +const BASE_SYMBOLS = ["l", "o", "w", "e", "r", "n", "s", "t", "i", "d"]; +const MERGED_SYMBOLS = ["es", "est", "lo", "low", "ne"]; + +interface WordMarks { + pair?: [string, string]; + merged?: string; +} + +const wordGroup = ( + word: string, + syms: string[], + marks: WordMarks = {}, +): SegmentGroup => { + const freq = CORPUS.find(([w]) => w === word)?.[1] ?? 1; + const tones: (TokenChipTone | undefined)[] = syms.map((sym) => + marks.merged && sym === marks.merged ? "match" : undefined, + ); + if (marks.pair) { + for (let i = 0; i < syms.length - 1; i += 1) { + if (syms[i] === marks.pair[0] && syms[i + 1] === marks.pair[1]) { + tones[i] = "active"; + tones[i + 1] = "active"; + } + } + } + return group( + `word-${word}`, + `${word} ×${freq}`, + syms.map((sym, i) => [sym, undefined, tones[i]]), + ); +}; + +/** Full corpus segmentation as a list of [word, symbols]. */ +type Splits = Record; + +const corpusGroups = (splits: Splits, marks: Record = {}) => + CORPUS.map(([word]) => wordGroup(word, splits[word], marks[word])); + +const SPLITS_0: Splits = { + low: ["l", "o", "w"], + lower: ["l", "o", "w", "e", "r"], + newest: ["n", "e", "w", "e", "s", "t"], + widest: ["w", "i", "d", "e", "s", "t"], +}; +const SPLITS_1: Splits = { + ...SPLITS_0, + newest: ["n", "e", "w", "es", "t"], + widest: ["w", "i", "d", "es", "t"], +}; +const SPLITS_2: Splits = { + ...SPLITS_1, + newest: ["n", "e", "w", "est"], + widest: ["w", "i", "d", "est"], +}; +const SPLITS_3: Splits = { + ...SPLITS_2, + low: ["lo", "w"], + lower: ["lo", "w", "e", "r"], +}; +const SPLITS_4: Splits = { + ...SPLITS_3, + low: ["low"], + lower: ["low", "e", "r"], +}; +const SPLITS_5: Splits = { + ...SPLITS_4, + newest: ["ne", "w", "est"], +}; + +/** Bars are scaled against the largest count seen in this corpus (9). */ +const pairBars = ( + counts: [pair: string, count: number][], + best?: string, +): MetricBar[] => + counts.map(([pair, count]) => ({ + id: `pair-${pair}`, + label: pair, + value: count / 9, + display: String(count), + tone: pair === best ? "pink" : "violet", + active: pair === best, + })); + +const PAIRS_0: [string, number][] = [ + ["e + s", 9], + ["s + t", 9], + ["w + e", 8], + ["l + o", 7], + ["o + w", 7], + ["n + e", 6], + ["e + w", 6], +]; +const PAIRS_1: [string, number][] = [ + ["es + t", 9], + ["w + e", 8], + ["l + o", 7], + ["o + w", 7], + ["n + e", 6], + ["e + w", 6], + ["w + i", 3], +]; +const PAIRS_2: [string, number][] = [ + ["l + o", 7], + ["o + w", 7], + ["n + e", 6], + ["e + w", 6], + ["w + est", 6], + ["w + i", 3], + ["w + e", 2], +]; +const PAIRS_3: [string, number][] = [ + ["lo + w", 7], + ["n + e", 6], + ["e + w", 6], + ["w + est", 6], + ["w + i", 3], + ["i + d", 3], + ["d + est", 3], +]; +const PAIRS_4: [string, number][] = [ + ["n + e", 6], + ["e + w", 6], + ["w + est", 6], + ["w + i", 3], + ["i + d", 3], + ["d + est", 3], + ["low + e", 2], +]; +const PAIRS_5: [string, number][] = [ + ["ne + w", 6], + ["w + est", 6], + ["w + i", 3], + ["i + d", 3], + ["d + est", 3], + ["low + e", 2], + ["e + r", 2], +]; + +const vocabChips = (mergeCount: number, latest?: string): TokenChip[] => + chips("vocab", [ + ...BASE_SYMBOLS.map((sym, i): ChipSpec => [sym, `#${i}`, "muted"]), + ...MERGED_SYMBOLS.slice(0, mergeCount).map( + (sym, i): ChipSpec => [ + sym, + `#${BASE_SYMBOLS.length + i}`, + sym === latest ? "match" : "violet", + ], + ), + ]); + +const bpeStats = (merges: number, corpusTokens: number): StatItem[] => [ + stat("merges", "merges", `${merges} / 5`, "amber"), + stat("vocab", "vocab size", String(BASE_SYMBOLS.length + merges), "green"), + stat("corpus", "corpus tokens", String(corpusTokens), "cyan"), +]; + +const bpeStep = ( + partial: Omit & { bytes?: TokenChip[] }, +): TokenizationStep => ({ bytes: [], ...partial }); + +const BPE_STEPS: TokenizationStep[] = [ + bpeStep({ + descriptionHtml: + "Training starts from characters. Every distinct character in the corpus becomes a base symbol, so the initial vocabulary has 10 entries and any text made of these letters can already be encoded.", + activeLine: 1, + doneLines: [], + segments: corpusGroups(SPLITS_0), + rules: vocabChips(0), + bars: [], + stats: bpeStats(0, 79), + }), + bpeStep({ + descriptionHtml: + "Each word is split into a list of single-character symbols. The corpus carries word frequencies (low 5, lower 2, newest 6, widest 3), so it currently costs 79 symbols to write out.", + activeLine: 2, + doneLines: [1], + segments: corpusGroups(SPLITS_0), + rules: vocabChips(0), + bars: [], + stats: bpeStats(0, 79), + }), + bpeStep({ + descriptionHtml: + "countPairs tallies every adjacent symbol pair, weighted by word frequency. e + s appears in newest (6) and widest (3), so it scores 9. Only the top pairs are shown.", + activeLine: 5, + doneLines: [1, 2, 3, 4], + segments: corpusGroups(SPLITS_0), + rules: vocabChips(0), + bars: pairBars(PAIRS_0), + stats: bpeStats(0, 79), + }), + bpeStep({ + descriptionHtml: + "argmax picks the most frequent pair. e + s and s + t tie at 9, and the tie goes to the pair encountered first, so e + s wins.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5], + segments: corpusGroups(SPLITS_0, { + newest: { pair: ["e", "s"] }, + widest: { pair: ["e", "s"] }, + }), + rules: vocabChips(0), + bars: pairBars(PAIRS_0, "e + s"), + stats: bpeStats(0, 79), + }), + bpeStep({ + descriptionHtml: + "The merge is recorded and the new symbol es joins the vocabulary as id 10. Merges are stored in order because encoding later replays them in exactly this sequence.", + activeLine: 9, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8], + segments: corpusGroups(SPLITS_0, { + newest: { pair: ["e", "s"] }, + widest: { pair: ["e", "s"] }, + }), + rules: vocabChips(1, "es"), + bars: pairBars(PAIRS_0, "e + s"), + stats: bpeStats(1, 79), + }), + bpeStep({ + descriptionHtml: + "applyMerge rewrites every word: each adjacent e, s becomes one es symbol. The corpus shrinks from 79 to 70 symbols (9 occurrences removed).", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + segments: corpusGroups(SPLITS_1, { + newest: { merged: "es" }, + widest: { merged: "es" }, + }), + rules: vocabChips(1, "es"), + bars: pairBars(PAIRS_0, "e + s"), + stats: bpeStats(1, 70), + }), + bpeStep({ + descriptionHtml: + "Second pass. Pairs are recounted over the new segmentation: s + t no longer exists because s was absorbed, and es + t takes its place with the same count of 9.", + activeLine: 5, + doneLines: [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12], + segments: corpusGroups(SPLITS_1), + rules: vocabChips(1), + bars: pairBars(PAIRS_1), + stats: bpeStats(1, 70), + }), + bpeStep({ + descriptionHtml: + "es + t is merged into est (id 11). Subwords grow by stacking earlier merges, which is why frequent suffixes like est end up as single tokens.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + segments: corpusGroups(SPLITS_2, { + newest: { merged: "est" }, + widest: { merged: "est" }, + }), + rules: vocabChips(2, "est"), + bars: pairBars(PAIRS_1, "es + t"), + stats: bpeStats(2, 61), + }), + bpeStep({ + descriptionHtml: + "Third recount. Note that w + e dropped from 8 to 2: inside newest the e now lives in est, so the pair only survives in lower. Earlier merges change what later merges can see.", + activeLine: 5, + doneLines: [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12], + segments: corpusGroups(SPLITS_2), + rules: vocabChips(2), + bars: pairBars(PAIRS_2), + stats: bpeStats(2, 61), + }), + bpeStep({ + descriptionHtml: + "l + o and o + w tie at 7. l + o was seen first, so it becomes lo (id 12) and both low and lower are rewritten.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + segments: corpusGroups(SPLITS_3, { + low: { merged: "lo" }, + lower: { merged: "lo" }, + }), + rules: vocabChips(3, "lo"), + bars: pairBars(PAIRS_2, "l + o"), + stats: bpeStats(3, 54), + }), + bpeStep({ + descriptionHtml: + "Fourth merge: lo + w becomes low (id 13). The word low is now a single token, and lower is low, e, r.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + segments: corpusGroups(SPLITS_4, { + low: { merged: "low" }, + lower: { merged: "low" }, + }), + rules: vocabChips(4, "low"), + bars: pairBars(PAIRS_3, "lo + w"), + stats: bpeStats(4, 47), + }), + bpeStep({ + descriptionHtml: + "Fifth merge: three pairs tie at 6 and n + e wins on first occurrence, producing ne (id 14). The corpus now costs 41 symbols instead of 79.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + segments: corpusGroups(SPLITS_5, { newest: { merged: "ne" } }), + rules: vocabChips(5, "ne"), + bars: pairBars(PAIRS_4, "n + e"), + stats: bpeStats(5, 41), + }), + bpeStep({ + descriptionHtml: + "NUM_MERGES is reached and the loop exits with 15 vocabulary entries and 5 ordered merge rules. An unseen word like lowest would encode as low + est, two known tokens, without any special casing.", + activeLine: 13, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + segments: corpusGroups(SPLITS_5), + rules: vocabChips(5), + bars: pairBars(PAIRS_5), + stats: bpeStats(5, 41), + }), +]; + +/* ------------------------------------------------------------------ */ +/* Example 2: encoding a sentence */ +/* ------------------------------------------------------------------ */ + +// Token ids and merge ranks below are illustrative. They follow the shape of +// a GPT-2 style byte-level BPE vocabulary but are not copied from one. + +const SENTENCE = "The tokenizer splits unusual words"; + +const PRE_TOKENS = ["The", `${SP}tokenizer`, `${SP}splits`, `${SP}unusual`, `${SP}words`]; + +const OUTPUT_IDS: [string, string][] = [ + ["The", "464"], + [`${SP}token`, "11241"], + ["izer", "7509"], + [`${SP}splits`, "30778"], + [`${SP}unusual`, "8468"], + [`${SP}words`, "2456"], +]; + +const preTokenGroup = (activeIndex: number, doneCount: number): SegmentGroup => + group( + "pre", + "pre-tokens", + PRE_TOKENS.map((piece, i): ChipSpec => [ + piece, + undefined, + i === activeIndex ? "active" : i < doneCount ? "match" : undefined, + ]), + ); + +const pieceGroup = (syms: ChipSpec[]): SegmentGroup => + group("piece", "current piece", syms); + +const outputGroup = (count: number): SegmentGroup => + group( + "out", + "output ids", + OUTPUT_IDS.slice(0, count).map(([label, id]): ChipSpec => [label, id, "amber"]), + true, + ); + +const byteChips = (text: string): TokenChip[] => + chips( + "byte", + [...new TextEncoder().encode(text)].map((byte, i): ChipSpec => [ + byte.toString(16).toUpperCase().padStart(2, "0"), + text[i] === " " ? SP : text[i], + byte === 0x20 ? "amber" : undefined, + ]), + ); + +const rule = (a: string, b: string, rank: number): ChipSpec => [ + `${a} + ${b}`, + `${a}${b} · rank ${rank}`, + "violet", +]; + +const TOKENIZER_RULES: ChipSpec[] = [ + rule(SP, "t", 0), + rule("e", "r", 13), + rule("e", "n", 26), + rule("o", "k", 456), + rule(`${SP}t`, "ok", 1204), + rule("i", "z", 4033), + rule("iz", "er", 7100), + rule(`${SP}tok`, "en", 9870), +]; + +const SPLITS_RULES: ChipSpec[] = [ + rule(SP, "s", 1), + rule("i", "t", 30), + rule("p", "l", 301), + rule("it", "s", 890), + rule(`${SP}s`, "pl", 2210), + rule(`${SP}spl`, "its", 20114), +]; + +const WORDS_RULES: ChipSpec[] = [ + rule(SP, "w", 8), + rule("o", "r", 42), + rule("d", "s", 611), + rule(`${SP}w`, "or", 1503), + rule(`${SP}wor`, "ds", 4890), +]; + +const encodeStats = (tokens: number, pieces?: number): StatItem[] => [ + stat("chars", "characters", "34", "slate"), + stat("words", "words", "5", "slate"), + stat("pieces", "pre-tokens", pieces == null ? "0" : String(pieces), "cyan"), + stat("tokens", "tokens", String(tokens), "amber"), +]; + +const ENCODE_STEPS: TokenizationStep[] = [ + { + descriptionHtml: + "encode receives the raw string. The model never sees characters directly; it sees a list of integer ids, so everything below is about turning 34 characters into as few ids as the vocabulary allows.", + activeLine: 1, + doneLines: [], + segments: [group("text", "input", [SENTENCE])], + rules: [], + bars: [], + bytes: [], + stats: encodeStats(0), + }, + { + descriptionHtml: + "preTokenize splits on a regex that keeps each leading space attached to the word after it. That is why most tokens in a vocabulary start with a space: the space is part of the token, not a separator between tokens.", + activeLine: 3, + doneLines: [1, 2], + segments: [preTokenGroup(-1, 0)], + rules: [], + bars: [], + bytes: [], + stats: encodeStats(0, 5), + }, + { + descriptionHtml: + "First piece, The, is split into single symbols. In a byte-level tokenizer each symbol is really a UTF-8 byte; for ASCII text one byte equals one character, so the byte view lines up with the letters.", + activeLine: 4, + doneLines: [1, 2, 3], + segments: [preTokenGroup(0, 0), pieceGroup(["T", "h", "e"])], + rules: [], + bars: [], + bytes: byteChips("The"), + stats: encodeStats(0, 5), + }, + { + descriptionHtml: + "The loop applies the lowest-ranked merge present in the piece, then repeats. T + h (rank 60) fires first, then Th + e (rank 145), and the piece collapses to one symbol.", + activeLine: 8, + doneLines: [1, 2, 3, 4, 5, 6, 7], + segments: [preTokenGroup(0, 0), pieceGroup([["The", undefined, "match"]])], + rules: chips("rules", [rule("T", "h", 60), rule("Th", "e", 145)]), + bars: [], + bytes: byteChips("The"), + stats: encodeStats(0, 5), + }, + { + descriptionHtml: + "syms.length is 1, so the loop ends and the vocabulary lookup maps The to id 464. One word, one token.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [preTokenGroup(0, 0), pieceGroup([["The", undefined, "match"]]), outputGroup(1)], + rules: chips("rules", [rule("T", "h", 60), rule("Th", "e", 145)]), + bars: [], + bytes: byteChips("The"), + stats: encodeStats(1, 5), + }, + { + descriptionHtml: + "Second piece, ␣tokenizer, starts as 10 symbols. The first one is the space byte 0x20, an ordinary symbol with its own merge rules.", + activeLine: 4, + doneLines: [1, 2, 3], + segments: [ + preTokenGroup(1, 1), + pieceGroup([SP, "t", "o", "k", "e", "n", "i", "z", "e", "r"]), + outputGroup(1), + ], + rules: [], + bars: [], + bytes: byteChips(" tokenizer"), + stats: encodeStats(1, 5), + }, + { + descriptionHtml: + "lowestRankPair scans every adjacent pair and returns the one with the smallest rank. ␣ + t is rank 0 (the most frequent pair in the training data), then e + r and e + n follow.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5], + segments: [ + preTokenGroup(1, 1), + pieceGroup([[`${SP}t`, undefined, "match"], "o", "k", ["en", undefined, "match"], "i", "z", ["er", undefined, "match"]]), + outputGroup(1), + ], + rules: chips("rules", TOKENIZER_RULES.slice(0, 3)), + bars: [], + bytes: byteChips(" tokenizer"), + stats: encodeStats(1, 5), + }, + { + descriptionHtml: + "Merges keep stacking: o + k (rank 456), then ␣t + ok (rank 1204). Rank order, not left-to-right order, decides what merges next, so the piece is rewritten in the same way it was during training.", + activeLine: 8, + doneLines: [1, 2, 3, 4, 5, 6, 7], + segments: [ + preTokenGroup(1, 1), + pieceGroup([[`${SP}tok`, undefined, "match"], "en", "i", "z", "er"]), + outputGroup(1), + ], + rules: chips("rules", TOKENIZER_RULES.slice(0, 5)), + bars: [], + bytes: byteChips(" tokenizer"), + stats: encodeStats(1, 5), + }, + { + descriptionHtml: + "i + z (rank 4033) and iz + er (rank 7100) build the suffix izer. The same suffix token shows up in optimizer and organizer, which is what lets the model generalise across word families.", + activeLine: 8, + doneLines: [1, 2, 3, 4, 5, 6, 7], + segments: [ + preTokenGroup(1, 1), + pieceGroup([`${SP}tok`, "en", ["izer", undefined, "match"]]), + outputGroup(1), + ], + rules: chips("rules", TOKENIZER_RULES.slice(0, 7)), + bars: [], + bytes: byteChips(" tokenizer"), + stats: encodeStats(1, 5), + }, + { + descriptionHtml: + "␣tok + en (rank 9870) yields ␣token. Two symbols remain: ␣token and izer.", + activeLine: 8, + doneLines: [1, 2, 3, 4, 5, 6, 7], + segments: [ + preTokenGroup(1, 1), + pieceGroup([[`${SP}token`, undefined, "match"], "izer"]), + outputGroup(1), + ], + rules: chips("rules", TOKENIZER_RULES), + bars: [], + bytes: byteChips(" tokenizer"), + stats: encodeStats(1, 5), + }, + { + descriptionHtml: + "No merge rule exists for ␣token + izer, so lowestRankPair returns nothing and the loop breaks. The word was too rare in training to earn its own token, so it costs 2 tokens.", + activeLine: 7, + doneLines: [1, 2, 3, 4, 5, 6, 8], + segments: [ + preTokenGroup(1, 1), + pieceGroup([[`${SP}token`, undefined, "active"], ["izer", undefined, "active"]]), + outputGroup(1), + ], + rules: chips("rules", TOKENIZER_RULES), + bars: [], + bytes: byteChips(" tokenizer"), + stats: encodeStats(1, 5), + }, + { + descriptionHtml: + "Both symbols are looked up and pushed: 11241 for ␣token and 7509 for izer. The model will receive these as two separate positions in its input sequence.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [ + preTokenGroup(1, 1), + pieceGroup([[`${SP}token`, undefined, "match"], ["izer", undefined, "match"]]), + outputGroup(3), + ], + rules: chips("rules", TOKENIZER_RULES), + bars: [], + bytes: byteChips(" tokenizer"), + stats: encodeStats(3, 5), + }, + { + descriptionHtml: + "␣splits goes through 6 merges and ends as a single symbol with id 30778. The merge count per word varies, but the output is always whatever symbols survive when no rule applies.", + activeLine: 8, + doneLines: [1, 2, 3, 4, 5, 6, 7, 9, 10], + segments: [ + preTokenGroup(2, 2), + pieceGroup([[`${SP}splits`, undefined, "match"]]), + outputGroup(4), + ], + rules: chips("rules", SPLITS_RULES), + bars: [], + bytes: byteChips(" splits"), + stats: encodeStats(4, 5), + }, + { + descriptionHtml: + "␣unusual (7 merges) and ␣words (5 merges) each become one token. Common English words almost always fit in a single token because their pairs were frequent enough to be merged all the way up.", + activeLine: 8, + doneLines: [1, 2, 3, 4, 5, 6, 7, 9, 10], + segments: [ + preTokenGroup(4, 4), + pieceGroup([[`${SP}words`, undefined, "match"]]), + outputGroup(6), + ], + rules: chips("rules", WORDS_RULES), + bars: [], + bytes: byteChips(" words"), + stats: encodeStats(6, 5), + }, + { + descriptionHtml: + "encode returns 6 ids for 5 words and 34 characters, about 5.7 characters per token. Token count, not word count, is what fills the context window and what API pricing meters, so unusual words and non-English text cost more.", + activeLine: 12, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + segments: [preTokenGroup(-1, 5), outputGroup(6)], + rules: [], + bars: [], + bytes: [], + stats: [...encodeStats(6, 5), stat("ratio", "chars / token", "5.7", "green")], + }, +]; + +/* ------------------------------------------------------------------ */ +/* Example 3: why counts differ */ +/* ------------------------------------------------------------------ */ + +const INPUTS = ["hello", "Hello", `${SP}hello`, "12345", `${SP}${SP}${SP}${SP}return`, "ხმა"]; + +const inputsGroup = (activeIndex: number, doneCount: number): SegmentGroup => + group( + "inputs", + "inputs", + INPUTS.map((text, i): ChipSpec => [ + text, + undefined, + i === activeIndex ? "active" : i < doneCount ? "match" : undefined, + ]), + ); + +const EDGE_OUTPUTS: [string, string][] = [ + ["hello", "31373"], + ["Hello", "15496"], + [`${SP}hello`, "23748"], + ["123", "10163"], + ["45", "2231"], + [`${SP}${SP}${SP}`, "27908"], + [`${SP}return`, "1441"], +]; + +const GEORGIAN_BYTES = ["E1", "83", "AE", "E1", "83", "9B", "E1", "83", "90"]; + +const edgeOutputGroup = (count: number, withBytes = false): SegmentGroup => + group( + "edge-out", + "output ids", + [ + ...EDGE_OUTPUTS.slice(0, count).map(([label, id]): ChipSpec => [label, id, "amber"]), + ...(withBytes + ? GEORGIAN_BYTES.map((hex): ChipSpec => [`0x${hex}`, String(parseInt(hex, 16)), "miss"]) + : []), + ], + true, + ); + +/** Rules listed in the order they fire, which is ascending rank. */ +const HELLO_RULES: ChipSpec[] = [ + rule("h", "e", 7), + rule("l", "l", 48), + rule("he", "ll", 2110), + rule("hell", "o", 18300), +]; + +const CAP_HELLO_RULES: ChipSpec[] = [ + rule("l", "l", 48), + rule("H", "e", 191), + rule("He", "ll", 3540), + rule("Hell", "o", 12520), +]; + +const INDENT_RULES: ChipSpec[] = [ + rule(SP, SP, 2), + rule(`${SP}${SP}`, SP, 51), +]; + +const RETURN_RULES: ChipSpec[] = [ + rule(SP, "r", 6), + rule("u", "r", 60), + rule(`${SP}r`, "e", 930), + rule("ur", "n", 1210), + rule("t", "urn", 2640), + rule(`${SP}re`, "turn", 4410), +]; + +const georgianBytes = (): TokenChip[] => + chips( + "gbyte", + GEORGIAN_BYTES.map((hex, i): ChipSpec => [hex, ["ხ", "მ", "ა"][Math.floor(i / 3)], "miss"]), + ); + +const edgeStats = (processed: number, tokens: number): StatItem[] => [ + stat("inputs", "inputs", `${processed} / 6`, "slate"), + stat("chars", "characters", "34", "slate"), + stat("tokens", "tokens", String(tokens), "amber"), +]; + +const EDGE_STEPS: TokenizationStep[] = [ + { + descriptionHtml: + "Six short inputs, 34 characters in total, run through the same encodePiece. The question is how many tokens each one costs, and the answer depends on bytes and merge rules rather than on what a human would call a word.", + activeLine: 2, + doneLines: [1], + segments: [inputsGroup(-1, 0)], + rules: [], + bars: [], + bytes: [], + stats: edgeStats(0, 0), + }, + { + descriptionHtml: + "TextEncoder turns hello into 5 UTF-8 bytes. Each byte maps to a printable placeholder symbol, so the merge loop only ever deals with a fixed alphabet of 256 base symbols and nothing is ever out of vocabulary.", + activeLine: 4, + doneLines: [1, 2, 3], + segments: [inputsGroup(0, 0)], + rules: [], + bars: [], + bytes: byteChips("hello"), + stats: edgeStats(0, 0), + }, + { + descriptionHtml: + "Four merges reduce hello to one symbol and the lookup returns 31373. A five-letter word in one token is the normal case for common lowercase English.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [inputsGroup(0, 0), edgeOutputGroup(1)], + rules: chips("rules", HELLO_RULES), + bars: [], + bytes: byteChips("hello"), + stats: edgeStats(1, 1), + }, + { + descriptionHtml: + "Hello differs from hello in exactly one byte: 0x48 instead of 0x68. BPE has no notion of case, so from here on this is simply a different byte sequence.", + activeLine: 4, + doneLines: [1, 2, 3], + segments: [inputsGroup(1, 1), edgeOutputGroup(1)], + rules: [], + bars: [], + bytes: byteChips("Hello"), + stats: edgeStats(1, 1), + }, + { + descriptionHtml: + "The capitalised form still merges to one symbol, but it is a different symbol with id 15496. The model has a separate embedding row for it, and it learns that the two are related only from context during training.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [inputsGroup(1, 1), edgeOutputGroup(2)], + rules: chips("rules", CAP_HELLO_RULES), + bars: [], + bytes: byteChips("Hello"), + stats: edgeStats(2, 2), + }, + { + descriptionHtml: + "␣hello carries the space byte and encodes to a third id, 23748. In running text almost every word follows a space, so the space-prefixed token is the common one; the bare hello mostly appears at the start of a line.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [inputsGroup(2, 2), edgeOutputGroup(3)], + rules: chips("rules", [rule(SP, "h", 5), rule("e", "l", 78), rule(`${SP}h`, "el", 1490), rule(`${SP}hel`, "lo", 6080)]), + bars: [], + bytes: byteChips(" hello"), + stats: edgeStats(3, 3), + }, + { + descriptionHtml: + "12345 is 5 digit bytes. Digits merge by frequency like everything else, and the merge table happens to contain 123 and 45 but not the full number.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5], + segments: [inputsGroup(3, 3), edgeOutputGroup(3)], + rules: chips("rules", [rule("1", "2", 340), rule("12", "3", 2870), rule("4", "5", 3115)]), + bars: [], + bytes: byteChips("12345"), + stats: edgeStats(3, 3), + }, + { + descriptionHtml: + "The number becomes 2 tokens, 123 and 45, split by merge statistics rather than by place value. This is one reason arithmetic is awkward for language models; newer tokenizers cap digit runs at 3 to make the chunking predictable.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [inputsGroup(3, 3), edgeOutputGroup(5)], + rules: chips("rules", [rule("1", "2", 340), rule("12", "3", 2870), rule("4", "5", 3115)]), + bars: [], + bytes: byteChips("12345"), + stats: edgeStats(4, 5), + }, + { + descriptionHtml: + "Indented code: the pre-tokenizer splits four spaces plus return into ␣␣␣ and ␣return, because the last space attaches to the word. Whitespace runs get their own tokens, so deeply indented code burns tokens on indentation alone.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [inputsGroup(4, 4), edgeOutputGroup(7)], + rules: chips("rules", [...INDENT_RULES, ...RETURN_RULES]), + bars: [], + bytes: byteChips(" return"), + stats: edgeStats(5, 7), + }, + { + descriptionHtml: + "ხმა (Georgian, 3 letters) is 9 bytes: every letter in the U+10D0 block needs 3 bytes in UTF-8. The byte view is now three times longer than the text.", + activeLine: 4, + doneLines: [1, 2, 3], + segments: [inputsGroup(5, 5), edgeOutputGroup(7)], + rules: [], + bars: [], + bytes: georgianBytes(), + stats: edgeStats(5, 7), + }, + { + descriptionHtml: + "This vocabulary learned no merges for these byte pairs, so lowestRankPair finds nothing on the first pass and the loop breaks immediately. This is the byte fallback: each byte is emitted as its own token from the reserved 0 to 255 range.", + activeLine: 7, + doneLines: [1, 2, 3, 4, 5, 6], + segments: [inputsGroup(5, 5), edgeOutputGroup(7, true)], + rules: chips("rules", [["no rule for E1 + 83", "fallback", "miss"], ["no rule for 83 + AE", "fallback", "miss"]]), + bars: [], + bytes: georgianBytes(), + stats: edgeStats(5, 7), + }, + { + descriptionHtml: + "Final tally: 34 characters became 16 tokens. The Georgian word alone costs 9 of them, three per letter, while hello costs 1. Token cost tracks how well the training data covered a script, not how long the text looks.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + segments: [inputsGroup(-1, 6), edgeOutputGroup(7, true)], + rules: [], + bars: [], + bytes: [], + stats: [ + ...edgeStats(6, 16), + stat("cheap", "hello", "1 token", "green"), + stat("expensive", "ხმა", "9 tokens", "rose"), + ], + }, +]; + +/* ------------------------------------------------------------------ */ +/* Examples */ +/* ------------------------------------------------------------------ */ + +export const EXAMPLES: TokenizationExample[] = [ + { + id: "bpe", + title: "BPE merges", + description: + "Train byte pair encoding on a tiny corpus: count adjacent pairs, merge the most frequent one, repeat.", + kind: "bpe", + codeLines: [ + { num: 1, text: "const vocab = new Set(words.flatMap((w) => [...w]));" }, + { num: 2, text: "const splits = new Map(words.map((w) => [w, [...w]]));" }, + { num: 3, text: "const merges = [];" }, + { num: 4, text: "while (merges.length < NUM_MERGES) {" }, + { num: 5, text: " const pairs = countPairs(splits, freq);" }, + { num: 6, text: " const best = argmax(pairs);" }, + { num: 7, text: " if (!best) break;" }, + { num: 8, text: " merges.push(best);" }, + { num: 9, text: " vocab.add(best[0] + best[1]);" }, + { num: 10, text: " for (const [word, syms] of splits) {" }, + { num: 11, text: " splits.set(word, applyMerge(syms, best));" }, + { num: 12, text: " }" }, + { num: 13, text: "}" }, + ], + steps: BPE_STEPS, + }, + { + id: "encode", + title: "Encoding a sentence", + description: + "Apply learned merges to a sentence, piece by piece, and map the surviving symbols to token ids.", + kind: "encode", + codeLines: [ + { num: 1, text: "function encode(text) {" }, + { num: 2, text: " const ids = [];" }, + { num: 3, text: " for (const piece of preTokenize(text)) {" }, + { num: 4, text: " let syms = [...piece];" }, + { num: 5, text: " while (syms.length > 1) {" }, + { num: 6, text: " const pair = lowestRankPair(syms, ranks);" }, + { num: 7, text: " if (!pair) break;" }, + { num: 8, text: " syms = applyMerge(syms, pair);" }, + { num: 9, text: " }" }, + { num: 10, text: " ids.push(...syms.map((s) => vocab.get(s)));" }, + { num: 11, text: " }" }, + { num: 12, text: " return ids;" }, + { num: 13, text: "}" }, + ], + steps: ENCODE_STEPS, + }, + { + id: "edge-cases", + title: "Why counts differ", + description: + "Case, leading spaces, digits, indentation, and non-Latin scripts all change how many tokens a string costs.", + kind: "edge-cases", + codeLines: [ + { num: 1, text: "const encoder = new TextEncoder();" }, + { num: 2, text: "function encodePiece(piece) {" }, + { num: 3, text: " const bytes = [...encoder.encode(piece)];" }, + { num: 4, text: " let syms = bytes.map((b) => byteToUnicode[b]);" }, + { num: 5, text: " while (syms.length > 1) {" }, + { num: 6, text: " const pair = lowestRankPair(syms, ranks);" }, + { num: 7, text: " if (!pair) break;" }, + { num: 8, text: " syms = applyMerge(syms, pair);" }, + { num: 9, text: " }" }, + { num: 10, text: " return syms.map((s) => vocab.get(s));" }, + { num: 11, text: "}" }, + ], + steps: EDGE_STEPS, + }, +]; diff --git a/src/components/visualizations/tokenization/helpers.ts b/src/components/visualizations/tokenization/helpers.ts index cb0ff5c..aab52ef 100644 --- a/src/components/visualizations/tokenization/helpers.ts +++ b/src/components/visualizations/tokenization/helpers.ts @@ -1 +1,56 @@ -export {}; +import { + createKindBadgeClass, + createKindLabel, +} from "@/lib/visualization-helpers"; +import type { StatTone, TokenizationKind } from "./types"; + +export const kindBadgeClass = createKindBadgeClass({ + bpe: "bg-amber-500/15 text-amber-400 border-amber-500/25", + encode: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25", + "edge-cases": "bg-rose-500/15 text-rose-400 border-rose-500/25", +}); + +export const kindLabel = createKindLabel({ + bpe: "bpe training", + encode: "encoding", + "edge-cases": "edge cases", +}); + +export interface PanelTitles { + segments: string; + rules: string; + aux: string; + stats: string; +} + +const PANEL_TITLES: Record = { + bpe: { + segments: "Current Segmentation", + rules: "Vocabulary", + aux: "Pair Frequencies", + stats: "Stats", + }, + encode: { + segments: "Tokens", + rules: "Merge Rules Applied", + aux: "Byte View", + stats: "Stats", + }, + "edge-cases": { + segments: "Tokens", + rules: "Vocabulary Lookup", + aux: "Byte View", + stats: "Stats", + }, +}; + +export const panelTitles = (kind: TokenizationKind): PanelTitles => + PANEL_TITLES[kind]; + +export const STAT_TONE_STYLES: Record = { + cyan: "border-cyan-400/30 text-cyan-200", + green: "border-emerald-400/30 text-emerald-200", + amber: "border-amber-400/30 text-amber-200", + rose: "border-rose-400/30 text-rose-200", + slate: "border-slate-600/50 text-slate-200", +}; diff --git a/src/components/visualizations/tokenization/index.tsx b/src/components/visualizations/tokenization/index.tsx index 83cd4b6..f921b6d 100644 --- a/src/components/visualizations/tokenization/index.tsx +++ b/src/components/visualizations/tokenization/index.tsx @@ -1,14 +1,174 @@ "use client"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { + CodeBlock, + type CodeBlockLine, +} from "@/components/visualization-ui/CodeBlock"; +import { TransportControls } from "@/components/visualization-ui/TransportControls"; import { ExampleSelector } from "@/components/visualization-ui/ExampleSelector"; +import { ToolbarPortal } from "@/components/layout/ToolbarPortal"; +import { cn } from "@/lib/utils"; +import { + VISUALIZATION_PANEL_TITLES, + VISUALIZATION_EMPTY_STATES, +} from "@/lib/visualization/uiCopy"; +import { useStepPlayback } from "@/hooks/useStepPlayback"; +import { useChangeFlash } from "@/hooks/useChangeFlash"; +import { EXAMPLES } from "./data"; +import { kindBadgeClass, kindLabel } from "./helpers"; +import { StatePanels } from "./components/StatePanels"; -// STUB: replaced by the real implementation. Keeps the build green while -// topics are authored in parallel. export const Tokenization = () => { - void ExampleSelector; + const [activeExampleId, setActiveExampleId] = useState(EXAMPLES[0].id); + + const handleExampleChange = (id: string) => { + setActiveExampleId(id); + }; + + const example = + EXAMPLES.find((e) => e.id === activeExampleId) ?? EXAMPLES[0]; + + const { + currentStepIndex, + isPlaying, + speedLevel, + speedLabel, + canStep, + canStepBack, + togglePlay, + step: handleStep, + stepBack: handleStepBack, + reset: handleReset, + setSpeedLevel, + jumpTo, + } = useStepPlayback({ + totalSteps: example.steps.length, + initialStep: -1, + resetKey: activeExampleId, + }); + + const currentStep = + currentStepIndex >= 0 ? example.steps[currentStepIndex] : null; + + const flashes = useChangeFlash( + { + description: currentStep?.descriptionHtml, + segments: currentStep?.segments, + rules: currentStep?.rules, + aux: example.kind === "bpe" ? currentStep?.bars : currentStep?.bytes, + stats: currentStep?.stats, + }, + currentStepIndex, + ); + return ( -

    - Tokenization visualization coming soon. -

    + <> + +
    +
    +
    + ( + + {kindLabel(ex.kind)} + + )} + /> + + {kindLabel(example.kind)} + +
    + + +
    + +
    + {currentStep?.descriptionHtml ? ( +

    + ) : ( +

    + {VISUALIZATION_EMPTY_STATES.stepDescription} +

    + )} +
    +
    +
    + +
    +
    + + { + const isActive = currentStep?.activeLine === line.num; + const isDone = + currentStep?.doneLines.includes(line.num) ?? false; + return { + key: line.num, + lineNumber: line.num, + text: line.text, + className: cn( + isActive && "is-active", + isDone && !isActive && "is-done", + ), + }; + })} + /> + + + +
    +
    + ); }; diff --git a/src/components/visualizations/tokenization/types.ts b/src/components/visualizations/tokenization/types.ts index cb0ff5c..bd4289d 100644 --- a/src/components/visualizations/tokenization/types.ts +++ b/src/components/visualizations/tokenization/types.ts @@ -1 +1,44 @@ -export {}; +import type { SourceLine } from "@/types/visualization"; +import type { ExampleOption } from "@/components/visualization-ui/ExampleSelector"; +import type { TokenChip } from "@/components/visualization-ui/TokenChips"; +import type { MetricBar } from "@/components/visualization-ui/MetricBars"; + +export type TokenizationKind = "bpe" | "encode" | "edge-cases"; + +export type StatTone = "cyan" | "green" | "amber" | "rose" | "slate"; + +/** A labelled row of chips, for example one corpus word or the output ids. */ +export interface SegmentGroup { + id: string; + label: string; + chips: TokenChip[]; + showIndex?: boolean; +} + +export interface StatItem { + id: string; + label: string; + value: string; + tone?: StatTone; +} + +export interface TokenizationStep { + descriptionHtml: string; + activeLine: number | null; + doneLines: number[]; + /** Tokens or per-word segmentation. */ + segments: SegmentGroup[]; + /** Vocabulary entries or merge rules applied so far. */ + rules: TokenChip[]; + /** Pair frequencies, used by the BPE training example only. */ + bars: MetricBar[]; + /** UTF-8 bytes of the piece being encoded, empty during training. */ + bytes: TokenChip[]; + stats: StatItem[]; +} + +export interface TokenizationExample extends ExampleOption { + kind: TokenizationKind; + codeLines: SourceLine[]; + steps: TokenizationStep[]; +} diff --git a/src/components/visualizations/vue-reactivity/components/BindingsPanel.tsx b/src/components/visualizations/vue-reactivity/components/BindingsPanel.tsx new file mode 100644 index 0000000..76c3033 --- /dev/null +++ b/src/components/visualizations/vue-reactivity/components/BindingsPanel.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/lib/utils"; +import { BINDING_LABELS, BINDING_STYLES } from "../helpers"; +import type { BindingEntry } from "../types"; + +export const BindingsPanel = ({ bindings }: { bindings: BindingEntry[] | null }) => { + if (!bindings || bindings.length === 0) { + return ( +

    + no bindings yet +

    + ); + } + + return ( +
      + {bindings.map((binding) => ( +
    • +
      + {binding.name} + = {binding.value} + + {BINDING_LABELS[binding.kind]} + +
      +

      {binding.note}

      +
    • + ))} +
    + ); +}; diff --git a/src/components/visualizations/vue-reactivity/components/ComputedPanel.tsx b/src/components/visualizations/vue-reactivity/components/ComputedPanel.tsx new file mode 100644 index 0000000..3d9eb84 --- /dev/null +++ b/src/components/visualizations/vue-reactivity/components/ComputedPanel.tsx @@ -0,0 +1,56 @@ +import { cn } from "@/lib/utils"; +import type { ComputedState } from "../types"; + +const Field = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
    + {label} + {children} +
    +); + +export const ComputedPanel = ({ computed }: { computed: ComputedState | null }) => { + if (!computed) { + return ( +

    + no computed yet +

    + ); + } + + return ( +
    +
    + ComputedRefImpl {computed.name} +
    +
    + + + {computed.dirty ? "true" : "false"} + + + + + {computed.cached} + + + + {computed.evaluations} + + + {computed.subscribers.length === 0 ? ( + none + ) : ( + {computed.subscribers.join(", ")} + )} + +
    +
    + ); +}; diff --git a/src/components/visualizations/vue-reactivity/components/DepMap.tsx b/src/components/visualizations/vue-reactivity/components/DepMap.tsx new file mode 100644 index 0000000..3a4b4ac --- /dev/null +++ b/src/components/visualizations/vue-reactivity/components/DepMap.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; +import { DEP_STATUS_STYLES } from "../helpers"; +import type { DepEntry } from "../types"; + +export const DepMap = ({ deps }: { deps: DepEntry[] }) => { + if (deps.length === 0) { + return ( +

    + targetMap is empty +

    + ); + } + + const targets = Array.from(new Set(deps.map((dep) => dep.target))); + + return ( +
    + {targets.map((target) => ( +
    +
    + + targetMap + + -> + toRaw({target}) + -> depsMap +
    +
      + {deps + .filter((dep) => dep.target === target) + .map((dep) => ( +
    • + "{dep.key}" + -> Dep + {dep.effects.length === 0 ? ( + {"{ }"} + ) : ( + dep.effects.map((effect) => ( + + {effect} + + )) + )} + {dep.status !== "idle" && ( + + {dep.status} + + )} +
    • + ))} +
    +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/vue-reactivity/components/SchedulerPanel.tsx b/src/components/visualizations/vue-reactivity/components/SchedulerPanel.tsx new file mode 100644 index 0000000..9f323fa --- /dev/null +++ b/src/components/visualizations/vue-reactivity/components/SchedulerPanel.tsx @@ -0,0 +1,76 @@ +import { cn } from "@/lib/utils"; +import type { SchedulerState } from "../types"; + +const Row = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
    + + {label} + + {children} +
    +); + +export const SchedulerPanel = ({ scheduler }: { scheduler: SchedulerState | null }) => { + if (!scheduler) { + return ( +

    + waiting +

    + ); + } + + return ( +
    + + {scheduler.activeEffect ? ( + + {scheduler.activeEffect} + + ) : ( + undefined + )} + + + + {scheduler.queue.length === 0 ? ( + [ ] + ) : ( + scheduler.queue.map((job) => ( + + {job} + + )) + )} + + + + + {scheduler.flushPending ? "flushJobs queued" : "idle"} + + + + {scheduler.ranNow.length > 0 && ( + + {scheduler.ranNow.map((effect) => ( + + {effect} + + ))} + + )} +
    + ); +}; diff --git a/src/components/visualizations/vue-reactivity/components/StatePanels.tsx b/src/components/visualizations/vue-reactivity/components/StatePanels.tsx new file mode 100644 index 0000000..f719e9f --- /dev/null +++ b/src/components/visualizations/vue-reactivity/components/StatePanels.tsx @@ -0,0 +1,53 @@ +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { cn } from "@/lib/utils"; +import type { VueReactivityKind, VueReactivityStep } from "../types"; +import { ComputedPanel } from "./ComputedPanel"; +import { BindingsPanel } from "./BindingsPanel"; +import { SchedulerPanel } from "./SchedulerPanel"; + +interface StatePanelsProps { + kind: VueReactivityKind; + step: VueReactivityStep | null; + flashes: { computed?: boolean; bindings?: boolean; scheduler?: boolean }; +} + +export const StatePanels = ({ kind, step, flashes }: StatePanelsProps) => { + const showComputed = kind === "computed"; + const showBindings = kind === "pitfalls"; + + return ( +
    + {showComputed && ( + + + + )} + {showBindings && ( + + + + )} + + + +
    + ); +}; diff --git a/src/components/visualizations/vue-reactivity/components/TrapLog.tsx b/src/components/visualizations/vue-reactivity/components/TrapLog.tsx new file mode 100644 index 0000000..43086a5 --- /dev/null +++ b/src/components/visualizations/vue-reactivity/components/TrapLog.tsx @@ -0,0 +1,54 @@ +import { cn } from "@/lib/utils"; +import { TRAP_RESULT_LABELS, TRAP_RESULT_STYLES } from "../helpers"; +import type { TrapEntry } from "../types"; + +export const TrapLog = ({ traps }: { traps: TrapEntry[] }) => { + if (traps.length === 0) { + return ( +

    + no traps fired +

    + ); + } + + return ( +
      + {traps.map((trap, index) => { + const isLatest = index === traps.length - 1; + return ( +
    1. +
      + + {trap.op} + + + {trap.target}.{trap.key} + + + {trap.op === "set" ? "=" : "->"} {trap.value} + + + {TRAP_RESULT_LABELS[trap.result]} + +
      +

      + {trap.isRef && ( + + ref accessor + + )} + {trap.note} +

      +
    2. + ); + })} +
    + ); +}; diff --git a/src/components/visualizations/vue-reactivity/data.ts b/src/components/visualizations/vue-reactivity/data.ts index cb0ff5c..f12c235 100644 --- a/src/components/visualizations/vue-reactivity/data.ts +++ b/src/components/visualizations/vue-reactivity/data.ts @@ -1 +1,750 @@ -export {}; +import type { + BindingEntry, + DepEntry, + SchedulerState, + TrapEntry, + VueReactivityExample, +} from "./types"; + +const IDLE: SchedulerState = { + activeEffect: null, + queue: [], + flushPending: false, + ranNow: [], +}; + +const sched = (patch: Partial): SchedulerState => ({ + ...IDLE, + ...patch, +}); + +const dep = ( + target: string, + key: string, + effects: string[], + status: DepEntry["status"] = "idle", +): DepEntry => ({ target, key, effects, status }); + +/* ------------------------------------------------------------------ */ +/* Example 1: track and trigger */ +/* ------------------------------------------------------------------ */ + +const t1GetCount: TrapEntry = { + id: "t1-get-count", + op: "get", + target: "state", + key: "count", + value: "0", + result: "track", + note: "activeEffect is effect#1, so track() adds it to the Dep for count.", +}; +const t1SetName: TrapEntry = { + id: "t1-set-name", + op: "set", + target: "state", + key: "name", + value: "'Bob'", + result: "miss", + note: "trigger() finds no Dep for name. The raw object is updated, nothing is scheduled.", +}; +const t1GetCount2: TrapEntry = { + id: "t1-get-count-2", + op: "get", + target: "state", + key: "count", + value: "0", + result: "noop", + note: "count++ reads first. activeEffect is undefined, so track() returns early.", +}; +const t1SetCount1: TrapEntry = { + id: "t1-set-count-1", + op: "set", + target: "state", + key: "count", + value: "1", + result: "trigger", + note: "hasChanged(0, 1) is true. trigger() schedules every effect in the Dep for count.", +}; +const t1GetCount3: TrapEntry = { + id: "t1-get-count-3", + op: "get", + target: "state", + key: "count", + value: "1", + result: "noop", + note: "Second count++ reads outside any effect. Nothing tracked.", +}; +const t1SetCount2: TrapEntry = { + id: "t1-set-count-2", + op: "set", + target: "state", + key: "count", + value: "2", + result: "trigger", + note: "trigger() runs again, but queueJob() skips effect#1 because it is already queued.", +}; +const t1GetCountFlush: TrapEntry = { + id: "t1-get-count-flush", + op: "get", + target: "state", + key: "count", + value: "2", + result: "track", + note: "Re-run inside flushJobs. activeEffect is effect#1 again, so the Dep is refreshed.", +}; + +const trackExample: VueReactivityExample = { + id: "track-trigger", + title: "Track and Trigger", + description: + "A Proxy get trap records which effect read a property, and the set trap re-runs only those effects.", + kind: "track", + codeLines: [ + { num: 1, text: "import { reactive, watchEffect, nextTick } from 'vue'" }, + { num: 2, text: "" }, + { num: 3, text: "const state = reactive({ count: 0, name: 'Ada' })" }, + { num: 4, text: "" }, + { num: 5, text: "watchEffect(() => {" }, + { num: 6, text: " console.log('double', state.count * 2)" }, + { num: 7, text: "})" }, + { num: 8, text: "" }, + { num: 9, text: "state.name = 'Bob'" }, + { num: 10, text: "state.count++" }, + { num: 11, text: "state.count++" }, + { num: 12, text: "await nextTick()" }, + ], + steps: [ + { + descriptionHtml: + 'reactive() wraps the plain object in a Proxy with get, set, has, and deleteProperty handlers, and caches the pair in a WeakMap so the same object always yields the same proxy. No trap has fired yet, so targetMap is empty.', + activeLine: 3, + doneLines: [1], + traps: [], + deps: [], + scheduler: IDLE, + computed: null, + bindings: null, + consoleOutput: [], + }, + { + descriptionHtml: + 'watchEffect() creates a ReactiveEffect around the callback and runs it immediately. Before the callback body executes, effect.run() sets the global activeEffect to effect#1 so any trap that fires knows who is reading.', + activeLine: 5, + doneLines: [1, 3], + traps: [], + deps: [], + scheduler: sched({ activeEffect: "effect#1" }), + computed: null, + bindings: null, + consoleOutput: [], + }, + { + descriptionHtml: + 'Reading state.count hits the get trap. It calls track(target, "count"), which looks up targetMap.get(raw), then depsMap.get("count"), creates the missing Dep, and adds effect#1 to it. This is the only place a dependency is ever recorded.', + activeLine: 6, + doneLines: [1, 3, 5], + traps: [t1GetCount], + deps: [dep("state", "count", ["effect#1"], "tracking")], + scheduler: sched({ activeEffect: "effect#1" }), + computed: null, + bindings: null, + consoleOutput: [], + }, + { + descriptionHtml: + 'The callback logs double 0 and returns. run() restores activeEffect to undefined. Note what is missing: state.name was never read, so there is no Dep for name and no effect will ever react to it.', + activeLine: 7, + doneLines: [1, 3, 5, 6], + traps: [t1GetCount], + deps: [dep("state", "count", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: null, + consoleOutput: ["double 0"], + }, + { + descriptionHtml: + 'state.name = \'Bob\' hits the set trap. The write lands on the raw object through Reflect.set, then trigger(target, "name") looks for a Dep and finds none. Nothing is scheduled: an untracked write is free.', + activeLine: 9, + doneLines: [1, 3, 5, 6, 7], + traps: [t1GetCount, t1SetName], + deps: [dep("state", "count", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: null, + consoleOutput: ["double 0"], + }, + { + descriptionHtml: + 'state.count++ is a read followed by a write. The get trap fires first, but activeEffect is undefined because this runs in module scope, so track() returns without touching the map.', + activeLine: 10, + doneLines: [1, 3, 5, 6, 7, 9], + traps: [t1GetCount, t1SetName, t1GetCount2], + deps: [dep("state", "count", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: null, + consoleOutput: ["double 0"], + }, + { + descriptionHtml: + 'The set trap writes 1, sees the value changed, and calls trigger(target, "count"). The Dep contains effect#1. Because watchEffect attaches a scheduler, the effect is not run inline: queueJob(effect#1) pushes it into the pre-flush queue and queues a single flushJobs microtask.', + activeLine: 10, + doneLines: [1, 3, 5, 6, 7, 9], + traps: [t1GetCount, t1SetName, t1GetCount2, t1SetCount1], + deps: [dep("state", "count", ["effect#1"], "triggering")], + scheduler: sched({ queue: ["effect#1 (pre)"], flushPending: true }), + computed: null, + bindings: null, + consoleOutput: ["double 0"], + }, + { + descriptionHtml: + 'The second state.count++ writes 2 and triggers again, but queueJob deduplicates: effect#1 is already in the queue, so it is not added twice. Two writes, one pending run. This is how Vue batches synchronous mutations.', + activeLine: 11, + doneLines: [1, 3, 5, 6, 7, 9, 10], + traps: [t1GetCount, t1SetName, t1GetCount2, t1SetCount1, t1GetCount3, t1SetCount2], + deps: [dep("state", "count", ["effect#1"], "triggering")], + scheduler: sched({ queue: ["effect#1 (pre)"], flushPending: true }), + computed: null, + bindings: null, + consoleOutput: ["double 0"], + }, + { + descriptionHtml: + 'await nextTick() yields to the microtask queue. nextTick returns the same promise the scheduler chained flushJobs onto, so the flush runs before this line resumes. The effect has still not re-run at this point.', + activeLine: 12, + doneLines: [1, 3, 5, 6, 7, 9, 10, 11], + traps: [t1GetCount, t1SetName, t1GetCount2, t1SetCount1, t1GetCount3, t1SetCount2], + deps: [dep("state", "count", ["effect#1"])], + scheduler: sched({ queue: ["effect#1 (pre)"], flushPending: true }), + computed: null, + bindings: null, + consoleOutput: ["double 0"], + }, + { + descriptionHtml: + 'flushJobs drains the queue and calls effect#1.run(). activeEffect is set again, the callback reads state.count, the get trap tracks it (Vue 3.4 compares Dep versions instead of clearing and re-adding), and the log shows double 4. One run for two writes.', + activeLine: 6, + doneLines: [1, 3, 5, 7, 9, 10, 11], + traps: [ + t1GetCount, + t1SetName, + t1GetCount2, + t1SetCount1, + t1GetCount3, + t1SetCount2, + t1GetCountFlush, + ], + deps: [dep("state", "count", ["effect#1"], "tracking")], + scheduler: sched({ activeEffect: "effect#1", ranNow: ["effect#1"] }), + computed: null, + bindings: null, + consoleOutput: ["double 0", "double 4"], + }, + { + descriptionHtml: + 'The flush finishes, the nextTick promise resolves, and line 12 continues. A component template is compiled into exactly this kind of effect, which is why a Vue component re-renders only when a property it actually read changes. React, by contrast, re-runs the whole component function on any state update it owns.', + activeLine: 12, + doneLines: [1, 3, 5, 6, 7, 9, 10, 11], + traps: [ + t1GetCount, + t1SetName, + t1GetCount2, + t1SetCount1, + t1GetCount3, + t1SetCount2, + t1GetCountFlush, + ], + deps: [dep("state", "count", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: null, + consoleOutput: ["double 0", "double 4"], + }, + ], +}; + +/* ------------------------------------------------------------------ */ +/* Example 2: computed caching */ +/* ------------------------------------------------------------------ */ + +const t2GetCount: TrapEntry = { + id: "t2-get-count", + op: "get", + target: "state", + key: "count", + value: "1", + result: "track", + note: "activeEffect is the computed's own effect, so the Dep for count now holds computed(double).", +}; +const t2SetCount: TrapEntry = { + id: "t2-set-count", + op: "set", + target: "state", + key: "count", + value: "5", + result: "trigger", + note: "trigger() notifies computed(double). A computed is only marked dirty, its getter does not run here.", +}; +const t2GetCount2: TrapEntry = { + id: "t2-get-count-2", + op: "get", + target: "state", + key: "count", + value: "5", + result: "track", + note: "Recompute on demand. The Dep already holds computed(double); its version is refreshed.", +}; + +const computedExample: VueReactivityExample = { + id: "computed-caching", + title: "Computed Caching", + description: + "A computed runs its getter lazily, caches the result, and only recomputes after a dependency changed.", + kind: "computed", + codeLines: [ + { num: 1, text: "import { reactive, computed } from 'vue'" }, + { num: 2, text: "" }, + { num: 3, text: "const state = reactive({ count: 1 })" }, + { num: 4, text: "" }, + { num: 5, text: "const double = computed(() => {" }, + { num: 6, text: " console.log('getter ran')" }, + { num: 7, text: " return state.count * 2" }, + { num: 8, text: "})" }, + { num: 9, text: "" }, + { num: 10, text: "console.log(double.value)" }, + { num: 11, text: "console.log(double.value)" }, + { num: 12, text: "state.count = 5" }, + { num: 13, text: "console.log('after write')" }, + { num: 14, text: "console.log(double.value)" }, + ], + steps: [ + { + descriptionHtml: + 'reactive() returns a Proxy around { count: 1 }. As before, nothing is tracked until an effect reads through it.', + activeLine: 3, + doneLines: [1], + traps: [], + deps: [], + scheduler: IDLE, + computed: null, + bindings: null, + consoleOutput: [], + }, + { + descriptionHtml: + 'computed() builds a ComputedRefImpl holding a ReactiveEffect around the getter. The getter is not called yet: a computed is lazy, so the instance starts dirty with no cached value and zero runs.', + activeLine: 5, + doneLines: [1, 3], + traps: [], + deps: [], + scheduler: IDLE, + computed: { + name: "double", + dirty: true, + cached: "undefined", + evaluations: 0, + subscribers: [], + }, + bindings: null, + consoleOutput: [], + }, + { + descriptionHtml: + 'Reading double.value enters the class getter on ComputedRefImpl. This read happens at module scope, so there is no outer effect to subscribe to the computed. The instance is dirty, so it calls effect.run() and activeEffect becomes the computed itself.', + activeLine: 10, + doneLines: [1, 3, 5, 8], + traps: [], + deps: [], + scheduler: sched({ activeEffect: "computed(double)" }), + computed: { + name: "double", + dirty: true, + cached: "undefined", + evaluations: 0, + subscribers: [], + }, + bindings: null, + consoleOutput: [], + }, + { + descriptionHtml: + 'The getter logs, then reads state.count. The get trap fires with activeEffect set to the computed, so track() records computed(double) in the Dep for count. A computed is a dependency consumer like any other effect.', + activeLine: 7, + doneLines: [1, 3, 5, 6, 8, 10], + traps: [t2GetCount], + deps: [dep("state", "count", ["computed(double)"], "tracking")], + scheduler: sched({ activeEffect: "computed(double)" }), + computed: { + name: "double", + dirty: true, + cached: "undefined", + evaluations: 0, + subscribers: [], + }, + bindings: null, + consoleOutput: ["getter ran"], + }, + { + descriptionHtml: + 'The getter returns 2. The computed stores it as _value, clears dirty, and the outer console.log prints it. In Vue 3.4+ the instance also records the global version counter so later reads can tell whether anything reactive changed since.', + activeLine: 10, + doneLines: [1, 3, 5, 6, 7, 8], + traps: [t2GetCount], + deps: [dep("state", "count", ["computed(double)"])], + scheduler: IDLE, + computed: { + name: "double", + dirty: false, + cached: "2", + evaluations: 1, + subscribers: [], + }, + bindings: null, + consoleOutput: ["getter ran", "2"], + }, + { + descriptionHtml: + 'The second read sees dirty === false. Vue 3.4 goes further: it compares the stored global version with the current one, they match, so it does not even walk the Dep list. The cached 2 is returned and the getter does not run. No trap fires because double is a class instance, not a Proxy.', + activeLine: 11, + doneLines: [1, 3, 5, 6, 7, 8, 10], + traps: [t2GetCount], + deps: [dep("state", "count", ["computed(double)"])], + scheduler: IDLE, + computed: { + name: "double", + dirty: false, + cached: "2", + evaluations: 1, + subscribers: [], + }, + bindings: null, + consoleOutput: ["getter ran", "2", "2"], + }, + { + descriptionHtml: + 'state.count = 5 hits the set trap and trigger() walks the Dep for count. For a computed subscriber, trigger does not re-run the getter. It bumps the Dep version, flags the computed dirty, and would notify the computed\'s own subscribers, of which there are none. The stale 2 stays in memory.', + activeLine: 12, + doneLines: [1, 3, 5, 6, 7, 8, 10, 11], + traps: [t2GetCount, t2SetCount], + deps: [dep("state", "count", ["computed(double)"], "triggering")], + scheduler: IDLE, + computed: { + name: "double", + dirty: true, + cached: "2", + evaluations: 1, + subscribers: [], + }, + bindings: null, + consoleOutput: ["getter ran", "2", "2"], + }, + { + descriptionHtml: + 'after write is logged and the getter count is still 1. Invalidation is eager, recomputation is deferred: a computed that nobody reads after a change costs nothing beyond flipping a flag.', + activeLine: 13, + doneLines: [1, 3, 5, 6, 7, 8, 10, 11, 12], + traps: [t2GetCount, t2SetCount], + deps: [dep("state", "count", ["computed(double)"])], + scheduler: IDLE, + computed: { + name: "double", + dirty: true, + cached: "2", + evaluations: 1, + subscribers: [], + }, + bindings: null, + consoleOutput: ["getter ran", "2", "2", "after write"], + }, + { + descriptionHtml: + 'The third read finds dirty === true (in 3.4 terms, the Dep version moved), so it runs the getter again. activeEffect is the computed, state.count is tracked once more, and getter ran prints a second time.', + activeLine: 7, + doneLines: [1, 3, 5, 6, 8, 10, 11, 12, 13], + traps: [t2GetCount, t2SetCount, t2GetCount2], + deps: [dep("state", "count", ["computed(double)"], "tracking")], + scheduler: sched({ activeEffect: "computed(double)" }), + computed: { + name: "double", + dirty: true, + cached: "2", + evaluations: 1, + subscribers: [], + }, + bindings: null, + consoleOutput: ["getter ran", "2", "2", "after write", "getter ran"], + }, + { + descriptionHtml: + 'The getter returns 10, the cache is replaced, and dirty clears. Two evaluations for three reads and one write. If a template read double.value, the render effect would appear under subscribers, and the write on line 12 would schedule that render, still without running the getter until the template asked for the value.', + activeLine: 14, + doneLines: [1, 3, 5, 6, 7, 8, 10, 11, 12, 13], + traps: [t2GetCount, t2SetCount, t2GetCount2], + deps: [dep("state", "count", ["computed(double)"])], + scheduler: IDLE, + computed: { + name: "double", + dirty: false, + cached: "10", + evaluations: 2, + subscribers: [], + }, + bindings: null, + consoleOutput: ["getter ran", "2", "2", "after write", "getter ran", "10"], + }, + ], +}; + +/* ------------------------------------------------------------------ */ +/* Example 3: ref vs reactive and losing reactivity */ +/* ------------------------------------------------------------------ */ + +const bCount = (value: string): BindingEntry => ({ + name: "count", + kind: "ref", + value: `RefImpl { value: ${value} }`, + note: "Primitives cannot be proxied. The RefImpl object owns a Dep and exposes it through .value.", +}); +const bState = (total: string): BindingEntry => ({ + name: "state", + kind: "reactive", + value: `Proxy { total: ${total} }`, + note: "Reads and writes go through the Proxy traps, so they can be tracked and triggered.", +}); +const bTotal: BindingEntry = { + name: "total", + kind: "plain", + value: "1", + note: "A copied number. No trap, no Dep, no way to notify anyone when state.total changes.", +}; +const bTotalRef = (total: string): BindingEntry => ({ + name: "totalRef", + kind: "toRef", + value: `ObjectRefImpl -> state.total (${total})`, + note: "Its .value getter forwards to state.total through the Proxy, so reads are tracked.", +}); + +const t3GetRef: TrapEntry = { + id: "t3-get-ref", + op: "get", + target: "count", + key: "value", + value: "0", + result: "track", + isRef: true, + note: "RefImpl getter calls trackRefValue(). effect#1 is added to the ref's own Dep.", +}; +const t3SetRef: TrapEntry = { + id: "t3-set-ref", + op: "set", + target: "count", + key: "value", + value: "1", + result: "trigger", + isRef: true, + note: "RefImpl setter calls triggerRefValue(). flush: 'sync' runs effect#1 immediately.", +}; +const t3GetTotalDestructure: TrapEntry = { + id: "t3-get-total-destructure", + op: "get", + target: "state", + key: "total", + value: "1", + result: "noop", + note: "Destructuring reads once at module scope. activeEffect is undefined, nothing is tracked.", +}; +const t3SetTotal2: TrapEntry = { + id: "t3-set-total-2", + op: "set", + target: "state", + key: "total", + value: "2", + result: "miss", + note: "trigger() finds no Dep for total. effect#2 never read through the proxy.", +}; +const t3GetTotalRef: TrapEntry = { + id: "t3-get-total-ref", + op: "get", + target: "state", + key: "total", + value: "2", + result: "track", + note: "totalRef.value forwarded to state.total inside effect#3, so the Dep for total is created.", +}; +const t3SetTotal3: TrapEntry = { + id: "t3-set-total-3", + op: "set", + target: "state", + key: "total", + value: "3", + result: "trigger", + note: "Dep for total holds effect#3. flush: 'sync' runs it inline.", +}; + +const pitfallsExample: VueReactivityExample = { + id: "ref-vs-reactive", + title: "Ref vs Reactive", + description: + "Reactivity lives in the ref or proxy object, not the variable. Destructuring copies the value out and severs the link; toRefs keeps it.", + kind: "pitfalls", + codeLines: [ + { num: 1, text: "import { ref, reactive, toRefs, watchEffect } from 'vue'" }, + { num: 2, text: "const sync = { flush: 'sync' }" }, + { num: 3, text: "" }, + { num: 4, text: "const count = ref(0)" }, + { num: 5, text: "watchEffect(() => console.log('ref', count.value), sync)" }, + { num: 6, text: "count.value++" }, + { num: 7, text: "" }, + { num: 8, text: "const state = reactive({ total: 1 })" }, + { num: 9, text: "const { total } = state" }, + { num: 10, text: "watchEffect(() => console.log('plain', total), sync)" }, + { num: 11, text: "state.total = 2" }, + { num: 12, text: "" }, + { num: 13, text: "const { total: totalRef } = toRefs(state)" }, + { num: 14, text: "watchEffect(() => console.log('toRef', totalRef.value), sync)" }, + { num: 15, text: "state.total = 3" }, + ], + steps: [ + { + descriptionHtml: + 'ref(0) returns a RefImpl: an object with a value accessor pair and its own Dep. A Proxy cannot wrap a number, so the wrapper object is what makes a primitive observable. flush: \'sync\' is used here so each effect re-runs inline instead of in the microtask flush.', + activeLine: 4, + doneLines: [1, 2], + traps: [], + deps: [], + scheduler: IDLE, + computed: null, + bindings: [bCount("0")], + consoleOutput: [], + }, + { + descriptionHtml: + 'effect#1 runs at once. Reading count.value enters the RefImpl getter, which calls trackRefValue(). Same idea as the Proxy trap, different entry point: the ref\'s Dep now holds effect#1.', + activeLine: 5, + doneLines: [1, 2, 4], + traps: [t3GetRef], + deps: [dep("count", "value", ["effect#1"], "tracking")], + scheduler: sched({ activeEffect: "effect#1" }), + computed: null, + bindings: [bCount("0")], + consoleOutput: ["ref 0"], + }, + { + descriptionHtml: + 'count.value++ goes through the setter. hasChanged(0, 1) is true, so triggerRefValue() notifies the Dep. With flush: \'sync\' the scheduler runs effect#1 immediately and ref 1 prints. Without the option it would be queued for the next microtask.', + activeLine: 6, + doneLines: [1, 2, 4, 5], + traps: [t3GetRef, t3SetRef], + deps: [dep("count", "value", ["effect#1"], "triggering")], + scheduler: sched({ ranNow: ["effect#1"] }), + computed: null, + bindings: [bCount("1")], + consoleOutput: ["ref 0", "ref 1"], + }, + { + descriptionHtml: + 'reactive() creates the Proxy for { total: 1 }. The Dep for count.value is untouched; each reactive source keeps its own entry in targetMap.', + activeLine: 8, + doneLines: [1, 2, 4, 5, 6], + traps: [t3GetRef, t3SetRef], + deps: [dep("count", "value", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: [bCount("1"), bState("1")], + consoleOutput: ["ref 0", "ref 1"], + }, + { + descriptionHtml: + 'Destructuring evaluates state.total exactly once, right now, at module scope. The get trap fires with no activeEffect, so nothing is tracked, and the local total receives the plain number 1. The proxy is out of the picture for this variable.', + activeLine: 9, + doneLines: [1, 2, 4, 5, 6, 8], + traps: [t3GetRef, t3SetRef, t3GetTotalDestructure], + deps: [dep("count", "value", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: [bCount("1"), bState("1"), bTotal], + consoleOutput: ["ref 0", "ref 1"], + }, + { + descriptionHtml: + 'effect#2 runs and reads the local total. No trap fires because a plain variable has no interceptor, so track() is never called. The effect finishes with zero dependencies, which means nothing can ever schedule it again.', + activeLine: 10, + doneLines: [1, 2, 4, 5, 6, 8, 9], + traps: [t3GetRef, t3SetRef, t3GetTotalDestructure], + deps: [dep("count", "value", ["effect#1"])], + scheduler: sched({ activeEffect: "effect#2" }), + computed: null, + bindings: [bCount("1"), bState("1"), bTotal], + consoleOutput: ["ref 0", "ref 1", "plain 1"], + }, + { + descriptionHtml: + 'state.total = 2 hits the set trap and trigger() looks up depsMap.get("total"). There is no Dep, so nothing runs. The proxy now says 2 while the local total still says 1, and the console does not change.', + activeLine: 11, + doneLines: [1, 2, 4, 5, 6, 8, 9, 10], + traps: [t3GetRef, t3SetRef, t3GetTotalDestructure, t3SetTotal2], + deps: [dep("count", "value", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: [bCount("1"), bState("2"), bTotal], + consoleOutput: ["ref 0", "ref 1", "plain 1"], + }, + { + descriptionHtml: + 'toRefs(state) returns a plain object whose every property is an ObjectRefImpl. Destructuring that object copies the ref wrapper, not the number, and the wrapper\'s .value getter reads state.total through the Proxy on every access.', + activeLine: 13, + doneLines: [1, 2, 4, 5, 6, 8, 9, 10, 11], + traps: [t3GetRef, t3SetRef, t3GetTotalDestructure, t3SetTotal2], + deps: [dep("count", "value", ["effect#1"])], + scheduler: IDLE, + computed: null, + bindings: [bCount("1"), bState("2"), bTotal, bTotalRef("2")], + consoleOutput: ["ref 0", "ref 1", "plain 1"], + }, + { + descriptionHtml: + 'effect#3 reads totalRef.value, which forwards to state.total. This time the get trap fires with activeEffect set, so track() creates the Dep for total and records effect#3. The log shows the current value, 2.', + activeLine: 14, + doneLines: [1, 2, 4, 5, 6, 8, 9, 10, 11, 13], + traps: [t3GetRef, t3SetRef, t3GetTotalDestructure, t3SetTotal2, t3GetTotalRef], + deps: [ + dep("count", "value", ["effect#1"]), + dep("state", "total", ["effect#3"], "tracking"), + ], + scheduler: sched({ activeEffect: "effect#3" }), + computed: null, + bindings: [bCount("1"), bState("2"), bTotal, bTotalRef("2")], + consoleOutput: ["ref 0", "ref 1", "plain 1", "toRef 2"], + }, + { + descriptionHtml: + 'state.total = 3 triggers the Dep for total, which now holds effect#3, and toRef 3 prints. effect#2 is still in no Dep and stays silent. The rule: reactivity is a property of the ref or proxy object, so copy the wrapper, never the value. In <script setup> the compiler unwraps refs in templates so you rarely write .value there.', + activeLine: 15, + doneLines: [1, 2, 4, 5, 6, 8, 9, 10, 11, 13, 14], + traps: [ + t3GetRef, + t3SetRef, + t3GetTotalDestructure, + t3SetTotal2, + t3GetTotalRef, + t3SetTotal3, + ], + deps: [ + dep("count", "value", ["effect#1"]), + dep("state", "total", ["effect#3"], "triggering"), + ], + scheduler: sched({ ranNow: ["effect#3"] }), + computed: null, + bindings: [bCount("1"), bState("3"), bTotal, bTotalRef("3")], + consoleOutput: ["ref 0", "ref 1", "plain 1", "toRef 2", "toRef 3"], + }, + ], +}; + +export const EXAMPLES: VueReactivityExample[] = [ + trackExample, + computedExample, + pitfallsExample, +]; diff --git a/src/components/visualizations/vue-reactivity/helpers.ts b/src/components/visualizations/vue-reactivity/helpers.ts index cb0ff5c..c83b36c 100644 --- a/src/components/visualizations/vue-reactivity/helpers.ts +++ b/src/components/visualizations/vue-reactivity/helpers.ts @@ -1 +1,58 @@ -export {}; +import { + createKindBadgeClass, + createKindLabel, +} from "@/lib/visualization-helpers"; +import type { + BindingKind, + DepStatus, + TrapResult, + VueReactivityKind, +} from "./types"; + +export const kindBadgeClass = createKindBadgeClass({ + track: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25", + computed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25", + pitfalls: "bg-rose-500/15 text-rose-400 border-rose-500/25", +}); + +export const kindLabel = createKindLabel({ + track: "track and trigger", + computed: "computed caching", + pitfalls: "ref vs reactive", +}); + +export const TRAP_RESULT_STYLES: Record = { + track: "border-emerald-500/30 bg-emerald-500/8 text-emerald-200", + trigger: "border-amber-500/30 bg-amber-500/8 text-amber-200", + noop: "border-slate-500/30 bg-slate-500/8 text-slate-400", + miss: "border-rose-500/30 bg-rose-500/8 text-rose-200", +}; + +export const TRAP_RESULT_LABELS: Record = { + track: "TRACK", + trigger: "TRIGGER", + noop: "NO-OP", + miss: "NO DEP", +}; + +export const DEP_STATUS_STYLES: Record = { + idle: "border-slate-600/40 bg-slate-800/30", + tracking: + "border-emerald-300/40 bg-emerald-400/10 shadow-[0_0_18px_rgba(52,211,153,0.1)]", + triggering: + "border-amber-300/40 bg-amber-400/10 shadow-[0_0_18px_rgba(251,191,36,0.1)]", +}; + +export const BINDING_STYLES: Record = { + reactive: "border-cyan-300/40 bg-cyan-400/10 text-cyan-200", + ref: "border-violet-300/40 bg-violet-400/10 text-violet-200", + toRef: "border-emerald-300/40 bg-emerald-400/10 text-emerald-200", + plain: "border-rose-400/40 bg-rose-400/10 text-rose-200", +}; + +export const BINDING_LABELS: Record = { + reactive: "reactive", + ref: "ref", + toRef: "toRef", + plain: "plain value", +}; diff --git a/src/components/visualizations/vue-reactivity/index.tsx b/src/components/visualizations/vue-reactivity/index.tsx index 5b4684d..d2e197b 100644 --- a/src/components/visualizations/vue-reactivity/index.tsx +++ b/src/components/visualizations/vue-reactivity/index.tsx @@ -1,14 +1,194 @@ "use client"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { + CodeBlock, + type CodeBlockLine, +} from "@/components/visualization-ui/CodeBlock"; +import { ConsoleOutput } from "@/components/visualization-ui/ConsoleOutput"; +import { TransportControls } from "@/components/visualization-ui/TransportControls"; import { ExampleSelector } from "@/components/visualization-ui/ExampleSelector"; +import { ToolbarPortal } from "@/components/layout/ToolbarPortal"; +import { cn } from "@/lib/utils"; +import { + VISUALIZATION_PANEL_TITLES, + VISUALIZATION_EMPTY_STATES, +} from "@/lib/visualization/uiCopy"; +import { useStepPlayback } from "@/hooks/useStepPlayback"; +import { useChangeFlash } from "@/hooks/useChangeFlash"; +import { EXAMPLES } from "./data"; +import { kindBadgeClass, kindLabel } from "./helpers"; +import { TrapLog } from "./components/TrapLog"; +import { DepMap } from "./components/DepMap"; +import { StatePanels } from "./components/StatePanels"; -// STUB: replaced by the real implementation. Keeps the build green while -// topics are authored in parallel. export const VueReactivity = () => { - void ExampleSelector; + const [activeExampleId, setActiveExampleId] = useState(EXAMPLES[0].id); + + const handleExampleChange = (id: string) => { + setActiveExampleId(id); + }; + + const example = + EXAMPLES.find((e) => e.id === activeExampleId) ?? EXAMPLES[0]; + + const { + currentStepIndex, + isPlaying, + speedLevel, + speedLabel, + canStep, + canStepBack, + togglePlay, + step: handleStep, + stepBack: handleStepBack, + reset: handleReset, + setSpeedLevel, + jumpTo, + } = useStepPlayback({ + totalSteps: example.steps.length, + initialStep: -1, + resetKey: activeExampleId, + }); + + const currentStep = + currentStepIndex >= 0 ? example.steps[currentStepIndex] : null; + + const flashes = useChangeFlash( + { + description: currentStep?.descriptionHtml, + traps: currentStep?.traps, + deps: currentStep?.deps, + scheduler: currentStep?.scheduler, + computed: currentStep?.computed, + bindings: currentStep?.bindings, + console: currentStep?.consoleOutput, + }, + currentStepIndex, + ); + return ( -

    - Vue Reactivity visualization coming soon. -

    + <> + +
    +
    +
    + ( + + {kindLabel(ex.kind)} + + )} + /> + + {kindLabel(example.kind)} + +
    + + +
    + +
    + {currentStep?.descriptionHtml ? ( +

    + ) : ( +

    + {VISUALIZATION_EMPTY_STATES.stepDescription} +

    + )} +
    +
    +
    + +
    +
    + + { + const isActive = currentStep?.activeLine === line.num; + const isDone = + currentStep?.doneLines.includes(line.num) ?? false; + return { + key: line.num, + lineNumber: line.num, + text: line.text, + className: cn( + isActive && "is-active", + isDone && !isActive && "is-done", + ), + }; + })} + /> + + +
    +
    + + + + + + + +
    + + + +
    + +
    +
    +
    +
    + ); }; diff --git a/src/components/visualizations/vue-reactivity/types.ts b/src/components/visualizations/vue-reactivity/types.ts index cb0ff5c..b229b78 100644 --- a/src/components/visualizations/vue-reactivity/types.ts +++ b/src/components/visualizations/vue-reactivity/types.ts @@ -1 +1,78 @@ -export {}; +import type { SourceLine } from "@/types/visualization"; +import type { ExampleOption } from "@/components/visualization-ui/ExampleSelector"; + +export type VueReactivityKind = "track" | "computed" | "pitfalls"; + +export type TrapOp = "get" | "set"; + +/** What the interceptor did with the access. */ +export type TrapResult = "track" | "trigger" | "noop" | "miss"; + +export interface TrapEntry { + id: string; + op: TrapOp; + /** Variable name holding the proxy or ref, e.g. "state" or "count". */ + target: string; + key: string; + /** Written value for set, read value for get. */ + value: string; + result: TrapResult; + note: string; + /** True when the access went through a ref accessor rather than a Proxy trap. */ + isRef?: boolean; +} + +export type DepStatus = "idle" | "tracking" | "triggering"; + +export interface DepEntry { + target: string; + key: string; + effects: string[]; + status: DepStatus; +} + +export interface SchedulerState { + activeEffect: string | null; + queue: string[]; + /** Whether a flushJobs microtask has been queued and not yet run. */ + flushPending: boolean; + /** Effects that ran synchronously in this step, e.g. flush: "sync" effects. */ + ranNow: string[]; +} + +export interface ComputedState { + name: string; + dirty: boolean; + cached: string; + evaluations: number; + /** Subscribers of the computed itself (effects that read .value). */ + subscribers: string[]; +} + +export type BindingKind = "reactive" | "ref" | "plain" | "toRef"; + +export interface BindingEntry { + name: string; + kind: BindingKind; + value: string; + /** Short note on whether reads of this binding can be tracked. */ + note: string; +} + +export interface VueReactivityStep { + descriptionHtml: string; + activeLine: number | null; + doneLines: number[]; + traps: TrapEntry[]; + deps: DepEntry[]; + scheduler: SchedulerState; + computed: ComputedState | null; + bindings: BindingEntry[] | null; + consoleOutput: string[]; +} + +export interface VueReactivityExample extends ExampleOption { + kind: VueReactivityKind; + codeLines: SourceLine[]; + steps: VueReactivityStep[]; +} diff --git a/src/content/theory/ai/attention.ts b/src/content/theory/ai/attention.ts index 3d7f2f6..5dde816 100644 --- a/src/content/theory/ai/attention.ts +++ b/src/content/theory/ai/attention.ts @@ -1,11 +1,88 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const attentionTheory: TopicTheoryContent = { - summary: "Attention theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "Self-attention lets every token in a sequence build a new representation by taking a weighted average of the other tokens, where the weights come from how well each token's query matches the others' keys. It is the operation that gives transformers their ability to relate words at any distance.", + whatItIs: [ + "Before attention, each token has a vector that only describes the token itself. Attention rewrites that vector so it also describes the token's context: after one attention layer, the vector for \"sat\" can carry information about \"cat\" because the layer let \"sat\" look at \"cat\" and copy part of it.", + "Three learned linear projections turn each input vector into a query, a key, and a value. The query is what the token is looking for, the key is what the token advertises about itself, and the value is the content it hands over when another token attends to it. Scores are dot products between one query and every key, softmax turns those scores into weights that sum to one, and the output is the weighted sum of the values.", + "The scores are divided by the square root of the key dimension before softmax. Dot products grow with the number of dimensions, so without scaling the softmax input would be large, the weights would collapse toward a single one-hot entry, and the gradient through softmax would be close to zero. Scaling keeps the distribution soft enough to train.", + "Because every query is scored against every key, the attention matrix is n by n for a sequence of n tokens. That quadratic cost in both time and memory is why context length is the expensive axis in a transformer and why so much engineering effort goes into sparse, linear, or cached variants of the same idea.", + ], + howItWorks: [ + "Step 1: each input embedding x is multiplied by three learned matrices to produce q = x Wq, k = x Wk, and v = x Wv. All tokens share the same matrices, so the projections are computed as one batched matmul.", + "Step 2: the raw score between query i and key j is the dot product q_i . k_j. Stacking all of them gives the matrix Q K^T with one row per query and one column per key.", + "Step 3: the score matrix is divided by sqrt(d_k). In a decoder, a causal mask then sets every entry where j > i to negative infinity, so a token cannot see positions that come after it.", + "Step 4: softmax is applied to each row. Exponentiating makes every entry positive and turns negative infinity into exactly zero, then dividing by the row sum makes each row a probability distribution over the keys.", + "Step 5: the output for token i is the sum over j of weight_ij times v_j. High-weight tokens contribute most of their value vector, low-weight tokens contribute almost nothing.", + "Step 6: multi-head attention runs several independent copies of steps 1 to 5 with smaller d_k each, concatenates their outputs, and multiplies by an output matrix Wo so the model can combine what the heads found.", + ], + commonMistakes: [ + { + title: "Applying the mask after softmax", + explanation: + "Zeroing future weights after softmax leaves the remaining weights summing to less than one, and the future positions still influenced the normaliser. The masked scores must be removed before the exponentials are summed.", + fix: "Fill masked positions with negative infinity (or a very large negative number) before calling softmax so exp of those entries is zero and the surviving weights renormalise correctly.", + }, + { + title: "Forgetting the sqrt(d_k) scaling", + explanation: + "With d_k of 64 or more the raw dot products are large, softmax saturates, and gradients through the attention weights vanish. Training stalls or the heads collapse onto single positions.", + fix: "Always divide the score matrix by sqrt(d_k), where d_k is the per-head key dimension, not the full model dimension.", + }, + { + title: "Treating keys and values as the same thing", + explanation: + "Keys and values come from different projections on purpose. The key decides how much attention a token receives, while the value decides what is copied. Sharing one matrix for both forces the thing that matches to also be the thing that is passed on.", + fix: "Keep Wk and Wv separate, and when reasoning about a head ask two questions: which tokens score highly, and what those tokens contribute once selected.", + }, + { + title: "Assuming attention is cheap for long inputs", + explanation: + "The weight matrix has n squared entries. Doubling the prompt length quadruples the attention work and the memory for the score matrix, which is why latency and cost scale so steeply with context.", + fix: "Budget context length deliberately, reuse the key-value cache during generation instead of recomputing past keys and values, and use attention variants designed for long sequences when the workload needs them.", + }, + ], + interviewQuestions: [ + { + question: "What do the query, key, and value represent in self-attention?", + answer: + "All three are linear projections of the same input token. The query encodes what the token is looking for, the key encodes what the token offers for matching, and the value encodes the content the token contributes to the output once it has been selected. Scores compare queries with keys, and the output mixes values.", + }, + { + question: "Why is the score matrix divided by sqrt(d_k)?", + answer: + "If the components of q and k have unit variance, their dot product has variance d_k, so scores grow with the key dimension. Large scores push softmax into a near one-hot regime with vanishing gradients. Dividing by sqrt(d_k) brings the variance back to one and keeps the weights trainable.", + codeExample: { + language: "python", + code: `scores = Q @ K.T # variance grows with d_k +scores = scores / math.sqrt(d_k) # back to unit variance +weights = softmax(scores, axis=-1) +out = weights @ V`, + }, + }, + { + question: "How does the causal mask work and why is it needed?", + answer: + "The mask sets every score where the key position is later than the query position to negative infinity before softmax, so those weights become exactly zero. During training all positions are predicted in parallel and position i is trained to predict token i plus one. Without the mask the model could read the answer through attention. At inference the future tokens do not exist yet, so the mask keeps training and generation consistent.", + codeExample: { + language: "python", + code: `n = scores.shape[-1] +future = torch.triu(torch.ones(n, n), diagonal=1).bool() +scores = scores.masked_fill(future, float("-inf")) +weights = softmax(scores, dim=-1) # exp(-inf) == 0`, + }, + }, + { + question: "What does multi-head attention add over a single head?", + answer: + "Each head has its own projections, so different heads can learn different relevance patterns, for example one head tracking adjacent tokens and another tracking the subject of the sentence. The head outputs are concatenated and mixed by an output matrix Wo. At the same model dimension the total cost is the same as one big head, but each head works in a smaller subspace.", + }, + { + question: "What is the time and memory complexity of self-attention, and what follows from it?", + answer: + "Computing Q K^T for n tokens costs O(n squared d) time and the score matrix takes O(n squared) memory per head. Context length is therefore the expensive axis: doubling it quadruples the attention work. This is why generation uses a key-value cache to avoid recomputing past keys and values, and why long-context models rely on sparse, windowed, or otherwise approximate attention.", + }, + ], relatedTopicIds: ["embeddings", "next-token-prediction", "tokenization", "backpropagation"], }; diff --git a/src/content/theory/ai/backpropagation.ts b/src/content/theory/ai/backpropagation.ts index f951a8a..382f28d 100644 --- a/src/content/theory/ai/backpropagation.ts +++ b/src/content/theory/ai/backpropagation.ts @@ -1,11 +1,93 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const backpropagationTheory: TopicTheoryContent = { - summary: "Backpropagation theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "Backpropagation is the algorithm that computes how much each parameter of a neural network contributed to the loss, by applying the chain rule backward through the computation graph. Gradient descent then nudges every parameter against its gradient so the loss goes down.", + whatItIs: [ + "A neural network is a long chain of simple operations: multiply by a weight, add a bias, apply an activation, repeat. The forward pass runs that chain on an input and ends with a loss, a single number that says how wrong the prediction was. Training means changing the weights so that number gets smaller.", + "To change a weight sensibly you need to know which direction lowers the loss and by how much. That quantity is the gradient dL/dw, the rate at which the loss changes per unit change of the weight. Backpropagation computes every parameter's gradient in one sweep from the loss back to the inputs, reusing the values saved during the forward pass.", + "The sweep works because of the chain rule. If the loss depends on w only through some intermediate value z, then dL/dw equals dL/dz multiplied by dz/dw. Each operation in the graph only has to know its own local derivative. Multiplying local derivatives along the path from the loss to a parameter gives the full gradient, and the partial products are shared between parameters that sit on the same path.", + "Deep learning frameworks implement this as automatic differentiation: they record the operations of the forward pass into a graph, and each operation ships with a rule for its backward step. The same machinery that trains a two-parameter neuron trains the attention and embedding matrices of a transformer with billions of parameters. Only the size of the graph changes.", + ], + howItWorks: [ + "Step 1: the forward pass computes the prediction from the input and the current parameters, saving every intermediate value (pre-activations, activations, which ReLU units were active) because the backward pass will need them.", + "Step 2: the loss function compares the prediction with the target and produces one scalar, for example the squared error (yHat - y)^2. The gradient of the loss with respect to itself is 1, which seeds the backward pass.", + "Step 3: the backward pass visits the graph in reverse order. At each node it multiplies the gradient arriving from the node above by that node's local derivative. For yHat = w * h the local derivative with respect to w is h and with respect to h is w. For ReLU it is 1 where the input was positive and 0 elsewhere.", + "Step 4: whenever the backward pass reaches a parameter, the accumulated product is that parameter's gradient. Gradients flowing into a non-parameter node keep travelling to the layer below, which is how the error signal reaches early layers.", + "Step 5: once all gradients exist, gradient descent updates every parameter at once: w = w - lr * dL/dw. Subtracting moves against the gradient, which is the direction of steepest loss increase, so the loss decreases for a small enough learning rate.", + "Step 6: the loop repeats with the new parameters. Gradients shrink as the prediction approaches the target, so steps naturally get smaller. Optimizers such as Adam and tricks such as learning rate schedules only change how the gradient is turned into a step, not how it is computed.", + ], + commonMistakes: [ + { + title: "Adding the gradient instead of subtracting it", + explanation: + "The gradient points in the direction that increases the loss fastest. Adding it walks uphill, and the loss grows on every step even though the gradients were computed correctly.", + fix: "Always update with w = w - lr * grad. If the loss increases on every iteration, check the sign of the update before anything else.", + }, + { + title: "Learning rate too large", + explanation: + "A gradient only describes the slope at the current point. A big step can jump past the minimum to a point where the loss is higher, and with a quadratic loss the error can even flip sign and grow every epoch.", + fix: "Start small (0.001 to 0.1 depending on the model), watch the loss curve, and reduce the rate if the loss oscillates or explodes. Learning rate schedules and adaptive optimizers exist for this reason.", + }, + { + title: "Updating parameters before all gradients are computed", + explanation: + "Every gradient in one backward pass is a derivative at the same set of parameter values. Changing w2 before computing dL/dw1 mixes derivatives from two different points on the loss surface.", + fix: "Compute the full backward pass first, then apply all updates. Frameworks enforce this by separating loss.backward() from optimizer.step().", + }, + { + title: "Forgetting that dead ReLU units block gradients", + explanation: + "ReLU's derivative is 0 for negative inputs. A unit whose pre-activation is negative for every training example passes zero gradient to everything upstream and never recovers.", + fix: "Use sensible weight initialisation, moderate learning rates, and consider leaky ReLU or GELU if a large share of units stops activating.", + }, + ], + interviewQuestions: [ + { + question: "What does a gradient like dL/dw actually mean?", + answer: + "It is the rate of change of the loss with respect to that parameter: if w increases by a tiny amount d, the loss changes by roughly dL/dw times d. A negative gradient means increasing w lowers the loss, which is why gradient descent moves in the opposite direction of the gradient.", + codeExample: { + language: "javascript", + code: `const x = 2.0, y = 2.0; +let w = 0.5, b = 0.1; +const yHat = w * x + b; // 1.1 +const loss = (yHat - y) ** 2; // 0.81 +const dYHat = 2 * (yHat - y); // -1.8 +const dW = dYHat * x; // -3.6: raising w lowers loss +const dB = dYHat; // -1.8`, + }, + }, + { + question: "How does the chain rule let backpropagation reach a weight in the first layer?", + answer: + "The loss depends on an early weight only through the intermediate values above it. The chain rule says the derivative along that path is the product of the local derivatives of each operation. Backpropagation computes the product incrementally from the loss backward, so by the time it reaches the first layer the incoming gradient already contains every factor from the layers above.", + codeExample: { + language: "javascript", + code: `// yHat = w2 * relu(w1 * x), loss = (yHat - y) ** 2 +const dYHat = 2 * (yHat - y); // dL/dyHat +const dW2 = dYHat * h; // local derivative of w2 * h wrt w2 is h +const dH = dYHat * w2; // wrt h it is w2, passed down +const dZ = dH * (z > 0 ? 1 : 0); // relu gate +const dW1 = dZ * x; // dL/dw1 = product of all factors`, + }, + }, + { + question: "Why do we subtract the gradient in the update, and what does the learning rate do?", + answer: + "The gradient is the direction of steepest increase of the loss, so subtracting a multiple of it moves toward lower loss. The learning rate scales that move. Too small and training takes many steps; too large and the step overshoots the minimum, which can make the loss oscillate or diverge.", + }, + { + question: "Why does the forward pass have to save intermediate values?", + answer: + "Local derivatives depend on the forward values. The derivative of w * h with respect to w is h, and ReLU's derivative depends on whether its input was positive. Without the saved activations the backward pass could not compute those factors. This is also why training uses far more memory than inference.", + }, + { + question: "How does this scale to a transformer with billions of parameters?", + answer: + "The mechanism is identical. Automatic differentiation records every tensor operation of the forward pass into a graph, and each operation has a known backward rule, including matrix multiplication, softmax, and layer norm. The backward pass sweeps the graph once and produces a gradient tensor for every weight matrix, and the optimizer applies the update to all of them in parallel on the GPU.", + }, + ], relatedTopicIds: ["attention", "embeddings", "next-token-prediction"], }; diff --git a/src/content/theory/ai/embeddings.ts b/src/content/theory/ai/embeddings.ts index 9b0a940..208f603 100644 --- a/src/content/theory/ai/embeddings.ts +++ b/src/content/theory/ai/embeddings.ts @@ -1,11 +1,94 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const embeddingsTheory: TopicTheoryContent = { - summary: "Embeddings theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "An embedding is a learned vector of numbers that stands in for a token, so that tokens used in similar contexts end up close together and the model can do arithmetic on meaning instead of on ids.", + whatItIs: [ + "A tokenizer turns text into integer ids, but an id is only an index: id 1 is not closer to id 2 than to id 500. The embedding layer replaces each id with a row from a matrix E of shape vocabulary size by embedding dimension. That row is the vector the rest of the network works with.", + "The values in E are parameters, learned by gradient descent alongside every other weight. Training starts them at small random numbers and adjusts the rows that appear in each batch so the model predicts text better. Tokens that show up in similar contexts receive similar gradients over time, which pushes their rows toward the same region of the space.", + "Because the vectors live in a geometric space, similarity becomes a measurement. Cosine similarity divides the dot product of two vectors by the product of their lengths and returns a value in [-1, 1]: 1 for the same direction, 0 for perpendicular, -1 for opposite. Retrieval, semantic search, and deduplication all rank candidates by this number.", + "In a transformer, the token embedding is not the whole input. Attention treats its inputs as a set, so the model adds a position signal to each token vector before the first layer. In GPT-2 that is a second learned table indexed by position, the original transformer used fixed sinusoids, and many recent models apply a rotation inside attention instead. Typical embedding widths run from 768 in GPT-2 small to 12288 in the largest GPT-3.", + ], + howItWorks: [ + "Step 1: the tokenizer maps the input text to a list of integer ids, one per token.", + "Step 2: for each id, the model reads row E[id] from the embedding matrix. This is an array index, not a matrix multiply, so the cost is tiny regardless of vocabulary size.", + "Step 3: the model reads a position vector for each slot i, from a learned table P[i] or a fixed formula, and adds it to the token vector: x_i = E[id_i] + P[i].", + "Step 4: the resulting rows form a matrix X of shape sequence length by embedding dimension, which is the input to the first attention block.", + "Step 5: during training, the loss gradient flows back through X into E. Only the rows that were looked up in the batch receive updates, so rare tokens are trained less often than common ones.", + "Step 6: at inference or in a retrieval system, two vectors are compared with cosine similarity: dot(a, b) / (|a| * |b|). The angle between them, not their lengths, is what carries the comparison.", + ], + commonMistakes: [ + { + title: "Treating token ids as if they carried meaning", + explanation: + "Ids are assigned by the tokenizer, often by merge order or frequency, so numeric closeness between ids says nothing about the tokens. Feeding raw ids into a dense layer forces the model to learn around an arbitrary ordering.", + fix: "Always pass ids through an embedding lookup first. The learned rows, not the ids, are the representation.", + }, + { + title: "Comparing vectors with the dot product alone", + explanation: + "The raw dot product grows with vector length, so a long vector scores high against everything. Frequent tokens often have larger norms, which biases nearest neighbour results toward them.", + fix: "Normalise both vectors or use cosine similarity, which divides by the norms and compares direction only. If you store normalised vectors, the dot product and cosine similarity become the same number.", + }, + { + title: "Forgetting position information", + explanation: + "Attention is permutation equivariant: shuffling the input rows shuffles the outputs the same way. With token embeddings only, the model cannot tell the cat sat from sat the cat.", + fix: "Add a position embedding to each token vector, or use a positional scheme such as RoPE inside attention, before the first transformer layer.", + }, + { + title: "Mixing embeddings from different models", + explanation: + "Each model learns its own space. The dimension may match, but the axes mean different things, so a vector from one model compared with a vector from another produces a number that is not a similarity at all.", + fix: "Embed queries and documents with the same model and version, and re-embed the whole corpus when the model changes.", + }, + ], + interviewQuestions: [ + { + question: "What is an embedding layer, and what does it compute?", + answer: + "It is a matrix with one row per vocabulary token and one column per dimension. Given a token id, it returns the row at that index. There is no multiplication involved, it is a lookup, and the rows are trained parameters that the rest of the network updates through backpropagation.", + codeExample: { + language: "javascript", + code: `// E has shape [vocabSize, d] +const ids = tokenize("cat dog car"); // [1, 2, 3] +const X = ids.map((id) => E[id]); // shape [3, d]`, + }, + }, + { + question: "How does cosine similarity differ from the dot product, and when does it matter?", + answer: + "Cosine similarity is the dot product divided by the product of the two norms, so it measures the angle between vectors and ignores their length. It matters when vectors have different magnitudes, which is common in learned embeddings, because the raw dot product would favour long vectors regardless of direction.", + codeExample: { + language: "javascript", + code: `const dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0); +const norm = (a) => Math.sqrt(dot(a, a)); +const cosine = (a, b) => dot(a, b) / (norm(a) * norm(b)); + +cosine([0.8, 0.5, 0.1], [0.6, 0.7, 0.1]); // 0.95`, + }, + }, + { + question: "Why do transformers need positional embeddings?", + answer: + "Self attention computes a weighted sum over the set of input vectors, and the weights depend only on the vectors themselves. Permuting the input permutes the output identically, so word order would be invisible. Adding a position dependent vector to each token gives the model a way to tell slots apart.", + codeExample: { + language: "javascript", + code: `const X = ids.map((id, i) => + tokEmb[id].map((v, k) => v + posEmb[i][k]) +);`, + }, + }, + { + question: "How are embedding values learned?", + answer: + "They are initialised randomly and updated by gradient descent like any other weight. The gradient of the loss with respect to the input vectors flows back into the rows that were looked up, so each training example nudges only the rows for the tokens it contains. Over many examples, tokens with similar contexts converge toward similar rows.", + }, + { + question: "Why can you not compare embeddings produced by two different models?", + answer: + "Each model learns its own coordinate system. Even with equal dimension, a direction that encodes one property in model A encodes something unrelated in model B, so cosine similarity across models is meaningless. A retrieval index has to be rebuilt whenever the embedding model changes.", + }, + ], relatedTopicIds: ["tokenization", "attention", "next-token-prediction"], }; diff --git a/src/content/theory/ai/next-token-prediction.ts b/src/content/theory/ai/next-token-prediction.ts index 61b4014..323fe73 100644 --- a/src/content/theory/ai/next-token-prediction.ts +++ b/src/content/theory/ai/next-token-prediction.ts @@ -1,11 +1,92 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const nextTokenPredictionTheory: TopicTheoryContent = { - summary: "Next Token Prediction theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "Next token prediction is the single operation a language model performs: given a sequence of token ids, produce a probability distribution over the vocabulary for what comes next. Text generation is that operation run in a loop, with a sampling rule deciding which token to append each time.", + whatItIs: [ + "A language model does not write sentences. It scores every token in its vocabulary as a candidate for the next position, and a decoding loop picks one, appends it, and asks again. Every word of a chat reply came out of this loop, one token at a time.", + "The forward pass ends with a linear layer (the lm head) that maps the last position's hidden vector to one real-valued score per vocabulary entry. These scores are logits: unbounded and only meaningful relative to each other. Softmax converts them into probabilities by exponentiating each score and dividing by the sum, so larger gaps between logits become larger ratios between probabilities.", + "Decoding strategies decide what to do with that distribution. Greedy decoding takes the argmax and is deterministic. Sampling draws a token in proportion to its probability, which is why the same prompt gives different answers on different runs. Temperature rescales the logits before softmax, top-k and top-p truncate the tail before drawing, and these are usually combined.", + "Only the last position's logits are used during generation, yet the model computes hidden states for every position. Inference engines avoid redoing that work with a key-value cache: attention keys and values from earlier positions are stored, so each new step processes only the newest token and attends over the cache. The cost per token is then roughly proportional to the current sequence length rather than to its square.", + ], + howItWorks: [ + "Step 1: the tokenizer converts the prompt into integer ids. The model receives ids, never raw text, and the id list is the state that the generation loop grows.", + "Step 2: a forward pass embeds every id, runs the sequence through the transformer blocks, and reads the hidden vector at the last position.", + "Step 3: the lm head multiplies that vector by the output embedding matrix, yielding one logit per vocabulary token, tens of thousands of scores for a single step.", + "Step 4: the logits are divided by the temperature T and passed through softmax. T below 1 sharpens the distribution toward the top token, T above 1 flattens it, and T = 0 is treated as pure argmax.", + "Step 5: an optional truncation removes the tail. Top-k keeps the k highest probabilities; top-p keeps the smallest set whose cumulative probability reaches p. The survivors are renormalized so they sum to 1 again.", + "Step 6: a token is drawn from the final distribution and appended to the ids. The loop repeats until an end-of-sequence token is drawn or a token budget runs out, reusing cached keys and values so earlier positions are not recomputed.", + ], + commonMistakes: [ + { + title: "Treating logits as probabilities", + explanation: + "Logits can be negative, exceed 1, and do not sum to anything in particular. Ranking them is fine, but reading a logit of 5.8 as 'very confident' ignores the values of every other candidate.", + fix: "Apply softmax (with the temperature you actually decode at) before interpreting confidence, and compare probabilities or log probabilities, not raw scores.", + }, + { + title: "Expecting reproducible output with temperature above 0", + explanation: + "Any nonzero temperature means a random draw at every step. Even at T = 0.5, tail tokens keep a small probability, and over hundreds of tokens a rare draw becomes likely.", + fix: "Use T = 0 (greedy) or a fixed random seed when outputs must be identical across runs, and remember that batching and floating point differences can still change results.", + }, + { + title: "Using top-k alone on peaked and flat distributions", + explanation: + "A fixed k keeps too few candidates when the model is unsure and too many junk candidates when it is certain. The right count changes from step to step.", + fix: "Prefer top-p, which sizes the candidate set by cumulative probability, or combine a generous top-k with top-p so the count adapts to the model's confidence.", + }, + { + title: "Applying truncation before temperature", + explanation: + "Temperature changes the probabilities that top-p measures against, so applying top-p first and temperature second produces a different candidate set than intended.", + fix: "Follow the conventional order: scale logits by temperature, softmax, then top-k and top-p, then draw. Check the order your inference library uses if results look off.", + }, + ], + interviewQuestions: [ + { + question: "What does a language model actually output at each step?", + answer: + "A vector of logits with one entry per vocabulary token, computed from the last position's hidden state. Softmax turns it into a probability distribution, and a decoding rule picks one token from it. The model itself never chooses; the sampler does.", + codeExample: { + language: "python", + code: `logits = model(ids)[-1] # shape: (vocab_size,) +probs = softmax(logits / T) # sums to 1 +next_id = sample(probs) # argmax when T == 0 +ids.append(next_id)`, + }, + }, + { + question: "How does temperature change the distribution, and can it reorder tokens?", + answer: + "Temperature divides every logit by T before softmax. Because division preserves order, the ranking of tokens never changes; only the gaps do. T below 1 widens the gaps and concentrates mass on the top token, T above 1 narrows them and spreads mass into the tail. In the limit T approaches 0 the distribution becomes one-hot on the argmax.", + }, + { + question: "What is the difference between top-k and top-p sampling?", + answer: + "Top-k keeps the k most probable tokens regardless of their mass. Top-p sorts tokens by probability and keeps the smallest prefix whose cumulative probability is at least p, so the candidate count grows when the model is uncertain and shrinks when it is confident. Both zero out the rest and renormalize before drawing.", + codeExample: { + language: "python", + code: `def top_p(probs, p): + order = argsort(probs)[::-1] + cum = cumsum(probs[order]) + cutoff = (cum >= p).argmax() + 1 + keep = order[:cutoff] + mask = zeros_like(probs) + mask[keep] = probs[keep] + return mask / mask.sum()`, + }, + }, + { + question: "Why is generation with a KV cache faster than recomputing the whole sequence?", + answer: + "Attention at each layer needs keys and values for every earlier position, but those do not change once computed because the model is causal. Caching them means a new step runs the transformer only for the newest token and attends over stored tensors. Without the cache each step costs work proportional to the full sequence length squared; with it the per-step cost is closer to linear in the sequence length.", + }, + { + question: "Why does the same prompt give different completions on different runs?", + answer: + "Because sampling draws each token from a probability distribution using a random number. Any token with nonzero probability can be chosen, and over a long completion the sequence of draws diverges quickly. Greedy decoding or a fixed seed removes this source of variation, though hardware nondeterminism in floating point reductions can still cause small differences.", + }, + ], relatedTopicIds: ["attention", "tokenization", "embeddings", "backpropagation"], }; diff --git a/src/content/theory/ai/tokenization.ts b/src/content/theory/ai/tokenization.ts index 890d258..104e196 100644 --- a/src/content/theory/ai/tokenization.ts +++ b/src/content/theory/ai/tokenization.ts @@ -1,11 +1,100 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const tokenizationTheory: TopicTheoryContent = { - summary: "Tokenization theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "Tokenization is the step that turns raw text into the sequence of integer ids a language model actually reads. Modern models use subword schemes such as byte pair encoding, so a token is usually a word fragment rather than a whole word or a single character.", + whatItIs: [ + "A language model cannot consume strings. Its first layer is an embedding table indexed by integers, so every input has to be converted into a list of ids drawn from a fixed vocabulary. The tokenizer is the deterministic function that performs that conversion, and its inverse, decoding, turns generated ids back into text.", + "Splitting on whitespace would make the vocabulary unbounded and leave every misspelling or new name out of vocabulary. Splitting into characters keeps the vocabulary tiny but makes sequences very long, and each position then carries almost no meaning. Subword tokenization sits between the two: frequent strings such as common words become one token, rare strings are broken into pieces the model has seen before.", + "Byte pair encoding learns those pieces from data. Training starts from base symbols, counts every adjacent pair across the corpus, merges the most frequent one into a new symbol, and repeats until the vocabulary reaches a target size. The ordered list of merges is the tokenizer. Encoding replays the same merges on new text, so training and inference always segment a string the same way.", + "Byte-level variants, used by GPT-2 and most models after it, take UTF-8 bytes rather than characters as the base symbols. That guarantees every possible input can be encoded with 256 base tokens and no unknown-token placeholder, at the cost that scripts which were rare in the training corpus, or which need several bytes per character, split into many more tokens than English does.", + ], + howItWorks: [ + "Step 1: a pre-tokenizer splits the text into pieces with a regex. In GPT-style tokenizers a leading space stays attached to the word after it, contractions are separated, and runs of whitespace or digits are isolated so merges never cross those boundaries.", + "Step 2: each piece is expanded into its base symbols, which are UTF-8 bytes mapped to printable placeholder characters in a byte-level tokenizer.", + "Step 3: the encoder repeatedly finds the adjacent pair with the lowest merge rank among the current symbols and replaces it with the merged symbol. Rank order, not left-to-right order, decides what merges next.", + "Step 4: the loop stops when no remaining adjacent pair has a merge rule. Whatever symbols survive are the tokens for that piece; a common word ends as one symbol while a rare one ends as several.", + "Step 5: every surviving symbol is looked up in the vocabulary to get its integer id, and the ids from all pieces are concatenated into the model input.", + "Step 6: the model treats each id as an opaque position. It has no view of the underlying letters, which is why spelling, counting characters, and arithmetic on long numbers are harder than they look.", + ], + commonMistakes: [ + { + title: "Estimating cost or context by word count", + explanation: + "Context windows and API pricing are measured in tokens. English prose averages roughly four characters per token, but code, numbers, unusual names, and non-Latin scripts can run at one to three tokens per word.", + fix: "Count tokens with the model's own tokenizer before truncating or budgeting. Never assume a fixed words-to-tokens ratio across languages or content types.", + }, + { + title: "Treating a word and its space-prefixed form as the same token", + explanation: + "In GPT-style vocabularies the space belongs to the following word, so 'hello' and ' hello' are different ids with different embeddings. Prompts that end with a trailing space or start a line without one can shift which token the model sees.", + fix: "Keep the boundary consistent with training data: end prompts without a trailing space and let the model produce the space-prefixed token itself.", + }, + { + title: "Mixing tokenizers across models", + explanation: + "Ids only mean something relative to the vocabulary they came from. Feeding ids from one tokenizer into another model, or comparing token counts across model families, silently produces nonsense.", + fix: "Load the tokenizer that ships with the exact model checkpoint and treat token counts as model-specific numbers.", + }, + { + title: "Expecting character-level reasoning", + explanation: + "A model sees 'strawberry' as one or two ids, not as a run of letters, so questions about letter counts or reversing strings work only when the model has memorised the spelling.", + fix: "Break the string into visible characters in the prompt, or hand the task to code. Do not rely on the model to see inside tokens.", + }, + ], + interviewQuestions: [ + { + question: "Why do language models use subword tokens instead of whole words?", + answer: + "Whole-word vocabularies are unbounded and any unseen word becomes an unknown token. Character vocabularies are tiny but produce very long sequences with little meaning per position. Subword tokenization keeps the vocabulary fixed while still encoding any string, and it lets common words be one token while rare words fall back to shared fragments such as prefixes and suffixes.", + }, + { + question: "Walk through how byte pair encoding is trained.", + answer: + "Start with each word split into base symbols and a frequency count per word. Count all adjacent symbol pairs weighted by frequency, merge the most frequent pair into a new symbol everywhere it occurs, add that symbol to the vocabulary, and record the merge. Repeat until the vocabulary reaches the target size. The ordered merge list is what encoding replays later.", + codeExample: { + language: "javascript", + code: `const merges = []; +while (merges.length < NUM_MERGES) { + const pairs = countPairs(splits, freq); // { "e s": 9, "s t": 9, ... } + const best = argmax(pairs); // ["e", "s"] + if (!best) break; + merges.push(best); + vocab.add(best[0] + best[1]); // "es" + for (const [word, syms] of splits) { + splits.set(word, applyMerge(syms, best)); + } +}`, + }, + }, + { + question: "How does encoding a new string use the learned merges?", + answer: + "Each pre-tokenized piece is split into base symbols, then the encoder repeatedly merges the adjacent pair with the lowest rank in the merge list until no pair has a rule. Applying merges in rank order reproduces the segmentation seen during training. The surviving symbols are mapped to ids through the vocabulary.", + codeExample: { + language: "javascript", + code: `function encodePiece(piece) { + let syms = [...piece]; + while (syms.length > 1) { + const pair = lowestRankPair(syms, ranks); + if (!pair) break; + syms = applyMerge(syms, pair); + } + return syms.map((s) => vocab.get(s)); +}`, + }, + }, + { + question: "Why does 'hello' tokenize differently from ' hello' and 'Hello'?", + answer: + "The tokenizer works on bytes and has no notion of case or word boundaries beyond what the pre-tokenizer regex encodes. A leading space is part of the byte sequence, so ' hello' is a different string with its own merge path and id. The same holds for a different first byte in 'Hello'. Each variant gets its own embedding and the model learns their relationship only from co-occurrence.", + }, + { + question: "What is byte fallback and why does it matter for non-English text?", + answer: + "In a byte-level tokenizer the 256 possible byte values are always in the vocabulary, so any UTF-8 input can be encoded without an unknown token. When no merge rules were learned for a script, every byte becomes its own token. A character that needs three bytes in UTF-8 then costs three tokens, which is why the same sentence can be several times more expensive in Georgian or Thai than in English.", + }, + ], relatedTopicIds: ["embeddings", "attention", "next-token-prediction"], }; diff --git a/src/content/theory/backend/database-indexing.ts b/src/content/theory/backend/database-indexing.ts index 0fc320a..99bca3a 100644 --- a/src/content/theory/backend/database-indexing.ts +++ b/src/content/theory/backend/database-indexing.ts @@ -1,11 +1,105 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const databaseIndexingTheory: TopicTheoryContent = { - summary: "Database Indexing theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], - relatedTopicIds: ["caching-strategies", "http-request-lifecycle", "rate-limiting", "heap-stack"], + summary: + "A database index is a separate, sorted data structure that maps column values to the rows that hold them, so a query can jump to matching rows in a handful of page reads instead of scanning the whole table.", + whatItIs: [ + "A table on disk is a heap: rows sit in pages in whatever order they were inserted. Finding every row where email equals a given value means reading every page and comparing every row, and that work grows in direct proportion to the table size. An index is a second structure, maintained alongside the table, that keeps the values of one or more columns in sorted order together with a pointer to each row.", + "Almost every relational database implements the default index as a B-tree. Internal pages hold separator keys and child pointers, leaf pages hold the actual keys with their row pointers (a page number and slot in PostgreSQL, a primary key value in InnoDB secondary indexes), and all leaves sit at the same depth. Because a single 8 KB page holds hundreds of keys, a tree that covers millions of rows is only three or four levels tall, so a lookup costs three or four page reads regardless of how many rows exist.", + "The query planner decides whether to use an index. It keeps statistics per column (number of distinct values, most common values, a histogram) and estimates how many rows a predicate will select. A highly selective predicate on an indexed column is served by an index scan. A predicate that matches a large fraction of the table is often cheaper as a sequential scan, since following thousands of row pointers into random heap pages costs more than reading the pages in order.", + "Indexes are not free. Every INSERT, every DELETE, and every UPDATE that touches an indexed column must also modify each affected index, and a full leaf page has to split, which writes two leaves and updates the parent. Indexes also take disk space and buffer pool memory. The engineering task is to create the few indexes that match the predicates and sort orders queries actually use, and to remove the ones that only slow writes down.", + ], + howItWorks: [ + "Step 1: a query arrives and the planner checks the catalog for indexes whose leading columns match the predicates, join conditions, or ORDER BY of the query.", + "Step 2: for each candidate plan the planner estimates cost from column statistics: how many rows the predicate selects, how many index pages and heap pages that implies, and whether the heap fetches would be random or sequential.", + "Step 3: with an index scan chosen, the executor reads the root page, compares the search key against the separators, and follows one child pointer per level until it reaches a leaf. Each level rules out all but one subtree.", + "Step 4: at the leaf, a binary search over the sorted keys finds the first matching entry. For a range or a prefix match the executor keeps walking forward through the leaf chain while the keys still match.", + "Step 5: each matching entry carries a row pointer, so the executor fetches that heap page and slot to return the full row. If every column the query needs is already in the index, the heap fetch is skipped entirely (an index only scan).", + "Step 6: on writes, the engine inserts the new key into every index in sorted position, splitting a full leaf and promoting a separator to the parent when necessary. The same descent that makes reads cheap makes each write cost one or more extra page writes per index.", + ], + commonMistakes: [ + { + title: "Composite index in the wrong column order", + explanation: + "An index on (last_name, first_name) sorts by last_name first. A filter on first_name alone has no leading prefix to descend by, so the planner cannot use the index and scans the table, even though the column is in the index.", + fix: "Order columns so that the ones used with equality in most queries come first, followed by the column used for a range or sort. Create a second index if two query shapes need different leading columns.", + }, + { + title: "Wrapping the indexed column in a function", + explanation: + "WHERE lower(email) = ... or WHERE date(created_at) = ... compares an expression, not the column the index stores. The planner cannot prove the expression preserves the index order, so the index is ignored.", + fix: "Either rewrite the predicate to compare the raw column, or create an expression index on lower(email) so the stored keys match the predicate exactly.", + }, + { + title: "Adding an index for every column", + explanation: + "Each index is another B-tree to update on every write. A table with ten indexes turns a single row insert into ten index inserts plus occasional page splits, and the extra pages compete for buffer pool memory with the data itself.", + fix: "Add indexes only for predicates and sorts that appear in real query plans, check usage statistics such as pg_stat_user_indexes, and drop indexes that are never scanned.", + }, + { + title: "Expecting an index to help a low selectivity predicate", + explanation: + "A filter like status = 'active' that matches 90 percent of the rows would force the executor to follow a row pointer into a random heap page for nearly every row, which is slower than reading the heap in order. The planner correctly chooses a sequential scan and the index sits unused.", + fix: "Index columns whose values narrow the result down, or use a partial index (WHERE status = 'pending') that only covers the small, frequently queried subset.", + }, + ], + interviewQuestions: [ + { + question: "Why is a B-tree lookup O(log n) and what does that mean in page reads?", + answer: + "Each internal page holds hundreds of separator keys, so every level divides the remaining search space by hundreds. A tree over a million rows is typically three or four levels tall, so a point lookup reads three or four index pages plus one heap page. A sequential scan on the same table reads every page, tens of thousands of them, and doubles when the table doubles.", + }, + { + question: "What is the leftmost prefix rule for composite indexes?", + answer: + "An index on (a, b, c) can serve predicates on a, on a and b, or on a, b, and c, because the keys are sorted by a first. A predicate on b alone or on c alone cannot descend the tree, since matching values are scattered under every a. Equality on a combined with a range on b still works, but a range on a followed by equality on b only uses the index for a.", + codeExample: { + language: "sql", + code: `CREATE INDEX users_name_idx ON users (last_name, first_name); + +-- uses the index (full key) +SELECT * FROM users WHERE last_name = 'Lovelace' AND first_name = 'Ada'; +-- uses the index (leading prefix) +SELECT * FROM users WHERE last_name = 'Lovelace'; +-- cannot use the index: no leading column +SELECT * FROM users WHERE first_name = 'Ada';`, + }, + }, + { + question: "What is a covering index and when does the planner use an index only scan?", + answer: + "A covering index contains every column the query reads, either as key columns or as INCLUDE columns. The executor can answer from the leaf entries and skip the heap fetch, as long as the visibility map shows the heap page has no rows that need a visibility check. This removes the random heap reads that make index scans expensive on wide result sets.", + codeExample: { + language: "sql", + code: `CREATE INDEX orders_customer_idx + ON orders (customer_id) INCLUDE (total, created_at); + +-- every referenced column is in the index: Index Only Scan +SELECT total, created_at FROM orders WHERE customer_id = 42;`, + }, + }, + { + question: "Why might the planner ignore an index that matches the WHERE clause?", + answer: + "The planner compares estimated costs, not just applicability. If the statistics say the predicate selects a large share of the table, the random heap fetches of an index scan cost more than a sequential read of the whole table. Stale statistics, a function around the column, or a type mismatch that forces a cast can also make the index unusable.", + }, + { + question: "What happens inside a B-tree when a leaf page is full and a new key arrives?", + answer: + "The leaf splits: roughly half its keys stay, the other half move to a newly allocated leaf, and the first key of the new leaf is copied into the parent as a separator. If the parent is also full it splits the same way, and a split of the root adds a new level. This keeps every leaf at the same depth, which is the balance property that guarantees the logarithmic lookup cost.", + codeExample: { + language: "sql", + code: `-- one row, three indexes: one heap write plus three B-tree inserts, +-- each of which may split a full leaf and update its parent +INSERT INTO users (id, email, last_name, first_name) +VALUES (13, 'donald@example.com', 'Knuth', 'Donald');`, + }, + }, + ], + relatedTopicIds: [ + "caching-strategies", + "http-request-lifecycle", + "rate-limiting", + "heap-stack", + ], }; diff --git a/src/content/theory/backend/http-request-lifecycle.ts b/src/content/theory/backend/http-request-lifecycle.ts index b76a1f9..00cd109 100644 --- a/src/content/theory/backend/http-request-lifecycle.ts +++ b/src/content/theory/backend/http-request-lifecycle.ts @@ -1,11 +1,113 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const httpRequestLifecycleTheory: TopicTheoryContent = { - summary: "HTTP Request Lifecycle theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], - relatedTopicIds: ["caching-strategies", "jwt-authentication", "rate-limiting", "event-loop", "promises"], + summary: + "The HTTP request lifecycle is everything that happens between a client calling fetch and the response body landing in its hands: DNS resolution, the TCP and TLS handshakes, the request on the wire, the server's middleware and handler, and the response coming back over a connection that usually stays open for the next call.", + whatItIs: [ + "HTTP is a text-based request and response protocol layered on top of a reliable byte stream. A request is a method, a path, a version, a set of headers, and an optional body. A response is a status line, headers, and an optional body. Nothing about HTTP itself says how those bytes reach the other machine, which is why a single fetch call involves several lower layers before any HTTP text is exchanged.", + "Before the first byte of HTTP can be sent the client needs an IP address (DNS), a connection (the TCP three-way handshake), and for https an encrypted channel with a verified peer (the TLS handshake). Each of these costs at least one network round trip, so on a 30 ms link a cold request spends roughly 20 ms on DNS, 30 ms on TCP, and 30 ms on TLS 1.3 before the request line even leaves the machine. Warm connections skip all three, which is why keep-alive and connection pooling matter so much.", + "On the server the request is parsed into an object and passed through an ordered chain of functions. In Express-style frameworks these are middleware: each one can read or modify the request, end the response, or call next() to pass control on. The router is one of those functions; it matches the method and path pattern, fills route params, and invokes the handler. Handlers are usually async because they wait on a database or another service, and that await suspends only that one request while the event loop keeps serving others.", + "The response carries a status code that classifies the outcome (2xx success, 3xx redirect, 4xx client error, 5xx server error) and headers that tell the client how to treat the body: its type, its length, whether it may be cached, and whether the connection can be reused. The client's promise resolves as soon as the headers are in; reading the body is a second asynchronous step because the body may still be streaming.", + ], + howItWorks: [ + "Step 1: the client parses the URL into scheme, host, port, and path, then resolves the host to an IP address. Browser and OS caches are checked first; on a miss a recursive resolver answers with an A or AAAA record and a TTL that controls how long the answer may be reused.", + "Step 2: the client opens a TCP connection with SYN, SYN-ACK, ACK. This costs one round trip and gives HTTP an ordered, reliable byte stream. HTTP/3 replaces TCP with QUIC over UDP to fold this handshake into the TLS one.", + "Step 3: for https the client sends a TLS ClientHello with its supported ciphers, an ephemeral key share, the server name (SNI), and ALPN protocol preferences. The server replies with its choice, its certificate chain, and its own key share. The client verifies the chain, both sides derive session keys, and TLS 1.3 completes this in one round trip where TLS 1.2 needed two.", + "Step 4: the client writes the request line and headers into the encrypted stream. Host is required in HTTP/1.1 because many sites share one IP. Over HTTP/2 the same connection carries many requests at once as interleaved streams; over HTTP/1.1 a connection handles one request at a time and browsers open up to six per host.", + "Step 5: the server's listener accepts the bytes, parses them into a request object, and walks the middleware chain: logging, body parsing, authentication, then the router. The matched handler does its work, typically awaiting a database query while the event loop handles other connections, then serialises a result and writes status, headers, and body.", + "Step 6: the client receives the status line and headers, resolves the fetch promise with a Response, and streams the body. If the response says keep-alive the socket stays open for the next request; if the status is a redirect the client may follow it automatically; if Cache-Control allows, a future identical request may not touch the network at all.", + ], + commonMistakes: [ + { + title: "Treating a 4xx or 5xx response as a rejected promise", + explanation: + "fetch only rejects on network failures such as DNS errors, refused connections, or CORS blocks. A 404 or 500 is a successfully delivered HTTP response, so the promise resolves and code that relies on catch alone silently continues with an error body.", + fix: "Check res.ok or res.status after every fetch and throw or branch explicitly. Keep catch for transport failures, which are a different class of problem and usually deserve a different retry strategy.", + }, + { + title: "Forgetting to call next() or ending the response twice", + explanation: + "A middleware that neither ends the response nor calls next() leaves the request hanging until the client times out. The opposite mistake, writing to the response and then also falling through to another res.send, throws ERR_HTTP_HEADERS_SENT because headers were already flushed with the first body chunk.", + fix: "Every middleware path must do exactly one of two things: end the response, or call next(). Use return res.status(...).json(...) so the function exits after responding, and pass errors with next(err) instead of throwing into the void.", + }, + { + title: "Blocking the event loop inside a handler", + explanation: + "An awaited database call costs the server almost nothing because the handler is suspended while I/O happens elsewhere. Synchronous CPU work such as a large JSON.parse, a tight loop, or a sync file read blocks the single thread, so every other in-flight request stalls until it finishes.", + fix: "Keep handlers I/O-bound and asynchronous. Move heavy CPU work to a worker thread, a queue, or a separate service, and measure event loop lag in production so a regression shows up as latency on unrelated endpoints.", + }, + { + title: "Opening a fresh connection for every request", + explanation: + "DNS, TCP, and TLS together cost two to three round trips before any HTTP bytes flow. Clients that disable keep-alive, or servers that send Connection: close, pay that price on every call and add load to the TLS termination layer.", + fix: "Leave keep-alive on, reuse a single agent or client instance with a connection pool for server-to-server calls, and prefer HTTP/2 where the origin supports it so many requests share one handshake.", + }, + ], + interviewQuestions: [ + { + question: + "What happens between calling fetch and the promise resolving on a cold connection?", + answer: + "The browser parses the URL, resolves the hostname through DNS, opens a TCP connection with a three-way handshake, negotiates TLS (one round trip for 1.3), sends the request line and headers, waits for the server to process the request, and resolves the promise as soon as the response status and headers have arrived. The body is read afterwards through a separate promise such as res.json().", + }, + { + question: + "Why does an await inside an Express handler not block other requests?", + answer: + "The await suspends only that handler's function. The database driver has written the query to a socket and registered a callback; control returns to the event loop, which keeps accepting and serving other connections. When the reply arrives, the promise resolves and the handler continuation runs as a microtask. Only synchronous CPU work blocks the loop.", + codeExample: { + language: "javascript", + code: `app.get('/products/:id', async (req, res) => { + // Suspends this handler only; the event loop stays free. + const product = await db.query( + 'SELECT * FROM products WHERE id = $1', + [req.params.id], + ); + res.status(200).json(product); +});`, + }, + }, + { + question: + "What is the difference between a 401 and a 403, and between a 301 and a 302?", + answer: + "401 Unauthorized means the request lacks valid credentials and should include a WWW-Authenticate header telling the client how to retry. 403 Forbidden means the credentials were fine but the action is not allowed. 301 Moved Permanently tells clients the resource has a new address and may be cached, so future requests skip the old URL. 302 Found is temporary and must not be cached; 307 and 308 are the variants that guarantee the method and body are preserved on the follow-up request.", + }, + { + question: + "How does Express route an error to the error-handling middleware?", + answer: + "Calling next(err) with an argument tells Express to skip every remaining normal middleware and route handler and jump to the first middleware declared with four parameters (err, req, res, next). That handler logs the error and sends a response, usually a generic 500 so internal details never reach the client. In Express 5 a rejected promise from an async handler is forwarded to next automatically.", + codeExample: { + language: "javascript", + code: `app.get('/products/:id', async (req, res, next) => { + try { + const product = await db.findProduct(req.params.id); + if (!product) return res.status(404).json({ error: 'Not found' }); + res.json(product); + } catch (err) { + next(err); // jumps to the 4-arity handler below + } +}); + +app.use((err, req, res, next) => { + console.error(err); + res.status(500).json({ error: 'Internal Server Error' }); +});`, + }, + }, + { + question: + "What does keep-alive change about the cost of a second request, and how does HTTP/2 go further?", + answer: + "With keep-alive the TCP and TLS session stay open after the first response, so a second request to the same host skips DNS, the TCP handshake, and the TLS handshake and pays only one round trip plus server time. HTTP/2 multiplexes many requests as interleaved streams over that single connection, removing head-of-line blocking at the HTTP layer and the need for six parallel connections per host.", + }, + ], + relatedTopicIds: [ + "caching-strategies", + "jwt-authentication", + "rate-limiting", + "event-loop", + "promises", + ], }; diff --git a/src/content/theory/backend/rate-limiting.ts b/src/content/theory/backend/rate-limiting.ts index b0537b6..4f1937e 100644 --- a/src/content/theory/backend/rate-limiting.ts +++ b/src/content/theory/backend/rate-limiting.ts @@ -1,11 +1,96 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const rateLimitingTheory: TopicTheoryContent = { - summary: "Rate Limiting theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "Rate limiting caps how many requests a client may make in a period of time. The server tracks usage per client key, accepts requests inside the budget, and answers the rest with 429 Too Many Requests and a Retry-After header.", + whatItIs: [ + "An API without a rate limit is only as available as its most aggressive caller. A buggy retry loop, a scraper, or a credential stuffing attack can consume all of the database connections and CPU that every other client depends on. Rate limiting gives each client a budget and enforces it before the request reaches expensive code.", + "Every limiter answers the same question, has this key made too many requests recently, but they differ in what they store. A fixed window keeps one counter per clock-aligned window. A token bucket keeps a fractional token count and a timestamp, refilling continuously and allowing bursts up to the capacity. A sliding window log keeps the timestamp of each accepted request and counts the ones inside the last N seconds, which is exact but costs one entry per request.", + "The key decides what is being protected. Limiting by IP address is the default at the edge and stops anonymous floods, but shared NATs and mobile carriers put thousands of users behind one address. Limiting by API key or user id after authentication is fairer and lets you sell tiers. Many systems apply both, a loose IP limit at the load balancer and a per-account limit in the application.", + "The state has to be shared. A Map inside one Node process only limits that process, so three instances behind a load balancer silently triple the budget. Production limiters keep counters in Redis and run the check and the update as one atomic operation, either INCR with EXPIRE or a short Lua script, so two instances cannot both read 2 and both accept when the limit is 3.", + ], + howItWorks: [ + "Step 1: the middleware derives a key from the request, typically the client IP, the API key, or the authenticated user id, sometimes combined with the route so a login endpoint has a tighter budget than a read endpoint.", + "Step 2: it loads the state for that key. For a token bucket that is the token count and the last refill time, for a fixed window it is the count and the window start, for a sliding log it is the list of recent timestamps.", + "Step 3: it brings the state up to the current time. The bucket adds elapsed seconds times the refill rate, capped at capacity. The fixed window resets the count if the clock has crossed into a new window. The sliding log drops timestamps older than now minus the window.", + "Step 4: it decides. If a token is available, or the count is under the limit, or fewer than the limit timestamps remain, the request is accepted and the state is updated (a token spent, the count incremented, the timestamp pushed). Otherwise the request is rejected and the state is left as it is, so rejected requests do not extend the lockout.", + "Step 5: the state is written back atomically. In Redis the load, update, and store happen inside one command or one Lua script so concurrent requests from other instances cannot interleave between the read and the write.", + "Step 6: the response carries the verdict. Accepted requests continue to the handler with X-RateLimit-Limit and X-RateLimit-Remaining headers. Rejected requests get 429 and a Retry-After value in whole seconds, computed from when the next token or slot becomes available and rounded up.", + ], + commonMistakes: [ + { + title: "Counting the window from a clock boundary", + explanation: + "A fixed window resets at t=10, t=20, and so on, so a client can send its full budget at t=9.9 and again at t=10.1. Two adjacent windows spent at the boundary give twice the nominal rate over a short interval.", + fix: "Use a sliding window log when exactness matters, or the sliding window counter approximation, prev * (1 - position) + current, when you want two integers per key and can tolerate a small error at the edges.", + }, + { + title: "Keeping limiter state in process memory", + explanation: + "A Map in a Node process is invisible to the other instances behind the load balancer. Each instance enforces its own copy of the limit, so the effective limit is multiplied by the instance count and resets on every deploy.", + fix: "Store the counter or bucket in Redis and perform the read, update, and write in one atomic step: INCR plus EXPIRE for fixed windows, or a Lua script for token buckets.", + }, + { + title: "Recording rejected requests in the log", + explanation: + "If a sliding log pushes the timestamp of every request, including rejected ones, a client that keeps retrying keeps pushing its own unlock time further away and can never recover, and the log grows without bound under attack.", + fix: "Only accepted requests count toward the limit. Push the timestamp inside the accepted branch, and prune expired entries on every call so the log never exceeds the limit in length.", + }, + { + title: "Returning 429 without Retry-After", + explanation: + "A bare 429 tells the client to stop but not for how long, so SDKs fall back to guessing, usually exponential backoff starting from a fixed value. That wastes capacity when the lockout is short and hammers the server when it is long.", + fix: "Compute the seconds until the next token or slot, round up, and send it as Retry-After. Add X-RateLimit-Remaining on every response so well-behaved clients can slow down before they hit the wall.", + }, + ], + interviewQuestions: [ + { + question: "What is the difference between a token bucket and a fixed window limiter?", + answer: + "A fixed window counts requests per clock-aligned interval and resets the count at the boundary, which is cheap but lets a client double its budget by straddling the reset. A token bucket refills continuously at a fixed rate up to a capacity, so it allows a burst up to the capacity while capping the long-run average, and it has no boundary to exploit.", + codeExample: { + language: "javascript", + code: `function allow(bucket, now, capacity = 5, ratePerSec = 1) { + const elapsed = (now - bucket.last) / 1000; + bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * ratePerSec); + bucket.last = now; + if (bucket.tokens < 1) return false; + bucket.tokens -= 1; + return true; +}`, + }, + }, + { + question: "How do you rate limit across multiple server instances?", + answer: + "Keep the state in a shared store, almost always Redis, and make the check and update atomic. For a fixed window, INCR the key for the current window and set EXPIRE on the first hit; the returned value tells you whether the request is over the limit. For a token bucket, run the refill and spend in a Lua script so no other instance can interleave between the read and the write.", + codeExample: { + language: "javascript", + code: `const key = \`rl:\${ip}:\${Math.floor(Date.now() / 10_000)}\`; +const count = await redis.incr(key); +if (count === 1) await redis.expire(key, 10); +if (count > 3) { + res.set("Retry-After", "10"); + return res.status(429).end(); +} +next();`, + }, + }, + { + question: "What does a 429 response mean and which headers should accompany it?", + answer: + "429 Too Many Requests means the client has exceeded its budget and the request was not processed. Retry-After tells the client how many seconds to wait, rounded up. X-RateLimit-Limit and X-RateLimit-Remaining, sent on every response, let clients pace themselves before they are rejected. The newer RateLimit-Policy header from the IETF draft standardizes the same information.", + }, + { + question: "What should you use as the rate limit key?", + answer: + "It depends on what you are protecting. IP address works for anonymous traffic at the edge but punishes users behind a shared NAT. After authentication, the API key or user id is fairer and supports per-tier limits. Sensitive endpoints such as login often combine keys, for example a per-IP limit and a per-account limit, so neither a distributed attack nor a single abusive account gets through.", + }, + { + question: "How does server-side rate limiting differ from debounce and throttle on the client?", + answer: + "Debounce and throttle run in the browser and reduce how often a cooperative client fires a call, which improves UX and saves bandwidth. They cannot protect the server, because an attacker controls the client code. Rate limiting runs on the server or at the edge and enforces the budget regardless of the client. Use throttle for typing and scrolling, and a server limiter for the API.", + }, + ], relatedTopicIds: ["http-request-lifecycle", "caching-strategies", "jwt-authentication", "debounce-throttle"], }; diff --git a/src/content/theory/frameworks/angular-change-detection.ts b/src/content/theory/frameworks/angular-change-detection.ts index e39f203..2b62a93 100644 --- a/src/content/theory/frameworks/angular-change-detection.ts +++ b/src/content/theory/frameworks/angular-change-detection.ts @@ -1,11 +1,110 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const angularChangeDetectionTheory: TopicTheoryContent = { - summary: "Angular Change Detection theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], - relatedTopicIds: ["vue-reactivity", "svelte-runes", "render-cycle", "memoization", "fiber-tree"], + summary: + "Change detection is the process Angular runs to find template bindings whose values changed and write the new values to the DOM. How it is triggered and how much of the tree it visits depends on whether the app uses zone.js, OnPush, or signals.", + whatItIs: [ + "Angular templates contain bindings such as {{ qty }} or [items]=\"items\". Angular does not know when the underlying values change, so it periodically re-evaluates the bindings, compares each result with the value it stored last time, and updates the DOM only where the result differs. This comparison is called dirty checking, and a full run of it is a change detection pass.", + "In a classic application the pass is triggered by zone.js. zone.js monkey-patches browser APIs such as addEventListener, setTimeout, Promise.then and XMLHttpRequest so that every callback runs inside an Angular zone. When the callback finishes and the microtask queue drains, NgZone emits onMicrotaskEmpty and ApplicationRef.tick() walks the whole component tree from the root, checking every binding in every Default-strategy component whether or not anything relevant changed.", + "ChangeDetectionStrategy.OnPush lets a component opt out of that blanket check. An OnPush view is skipped unless something marks it dirty: an @Input received a new reference (compared with Object.is), a template event fired inside the view, an async pipe emitted, or ChangeDetectorRef.markForCheck() was called. Marking a view also marks its ancestors, so the next tick can reach it without checking the siblings along the way.", + "Signals make the trigger precise instead of coarse. A template that reads a signal registers itself as a consumer in a reactive graph. Writing the signal marks its consumers dirty, which flags exactly those views for refresh and asks the scheduler for a tick. In a zoneless app (provideZonelessChangeDetection) that notification is the only trigger, so the traversal visits the flagged views and passes through their ancestors without evaluating anything else.", + ], + howItWorks: [ + "Step 1: something notifies Angular. With zone.js it is a patched async callback finishing; with OnPush it is an input reference change, a template event, an async pipe, or markForCheck(); with signals it is a signal write that marks a consuming view dirty.", + "Step 2: the scheduler coalesces notifications from the same turn into one ApplicationRef.tick(). The zoneless scheduler races setTimeout against requestAnimationFrame and runs on whichever fires first.", + "Step 3: tick() starts at the root view and walks children in template order. A Default view is always checked. An OnPush view is checked only when its dirty flag is set. A view that only has HasChildViewsToRefresh is passed through without evaluating its own bindings.", + "Step 4: checking a view runs its template update function. Each binding expression is re-evaluated and compared with the stored value using Object.is; on a difference the DOM property or text node is written and the stored value is replaced.", + "Step 5: input bindings on child components are compared the same way. A changed reference calls the input setter, fires ngOnChanges, and marks an OnPush child dirty so the traversal descends into it.", + "Step 6: in development mode tick() runs a second pass, checkNoChanges(), that re-evaluates every binding without writing. Any value that differs from the first pass throws ExpressionChangedAfterItHasBeenCheckedError, which reveals bindings that mutate state during rendering.", + ], + commonMistakes: [ + { + title: "Mutating an OnPush input in place", + explanation: + "Pushing into an array or changing a field on an object keeps the same reference, so the parent's input binding compares equal and the OnPush child is never marked dirty. The model changes, the DOM does not.", + fix: "Treat inputs as immutable and produce a new reference for every change (spread, map, filter). If the change originates inside the component, call ChangeDetectorRef.markForCheck() or store the value in a signal.", + }, + { + title: "Expecting third-party callbacks to trigger a check", + explanation: + "Code that runs outside the Angular zone, such as a callback from a library that captured the native setTimeout before zone.js patched it, or anything scheduled with NgZone.runOutsideAngular, finishes without notifying NgZone, so no tick follows.", + fix: "Wrap the callback in NgZone.run(), or in a zoneless or signals-based design write the result into a signal so the consuming view is marked dirty explicitly.", + }, + { + title: "Changing state during change detection", + explanation: + "Setting a parent's value from a child's ngAfterViewInit, or calling a method with side effects from a template, changes a binding that has already been checked in the same pass. Dev mode reports it as ExpressionChangedAfterItHasBeenCheckedError; production silently renders stale output.", + fix: "Move the update to a point before the check (ngOnInit, a signal computed, or an effect) rather than working around the error with setTimeout or detectChanges().", + }, + { + title: "Reading a signal outside a reactive context and expecting updates", + explanation: + "Calling count() once inside a constructor or a plain method copies the value; it does not subscribe anything. Only reads inside a template, computed, or effect create graph edges.", + fix: "Read signals where the value is consumed: in the template, in a computed() that the template reads, or in an effect() when you need a side effect.", + }, + ], + interviewQuestions: [ + { + question: "What does zone.js do for Angular, and what does it not do?", + answer: + "zone.js patches async browser APIs so that callbacks run inside an Angular zone and Angular is notified when they finish. That notification triggers ApplicationRef.tick(). zone.js does not know what the callback changed, which is why the default pass checks every binding in every Default component.", + }, + { + question: "When is an OnPush component checked?", + answer: + "When one of its @Input references changes (Object.is comparison), when a template event listener inside it fires, when an async pipe in its template emits, when markForCheck() is called, or when a signal read in its template is written. Otherwise the whole subtree is skipped during the pass.", + codeExample: { + language: "typescript", + code: `@Component({ + selector: 'app-list', + changeDetection: ChangeDetectionStrategy.OnPush, + template: \`@for (it of items; track it.id) { + + }\`, +}) +export class ListComponent { + @Input() items: Item[] = []; +} + +// parent: same reference, List is skipped +this.items[0].qty++; +// parent: new reference, List is marked dirty +this.items = this.items.map((it, i) => + i === 0 ? { ...it, qty: it.qty + 1 } : it);`, + }, + }, + { + question: "What is ExpressionChangedAfterItHasBeenCheckedError and why does it only appear in development?", + answer: + "After the real pass, dev mode runs checkNoChanges(), which re-evaluates every binding without writing to the DOM. If any expression now returns a different value, some code changed state during the pass, and Angular throws to surface it. Production skips the verification pass for performance, so the same bug renders stale output instead of throwing.", + }, + { + question: "How do signals make change detection local rather than global?", + answer: + "Reading a signal in a template records a producer to consumer edge. Writing the signal marks its consumers dirty, which flags the exact views that read it for refresh and marks their ancestors for traversal only. The next tick descends through those ancestors without evaluating their bindings and refreshes only the flagged views, so the work is proportional to the readers of the changed value.", + codeExample: { + language: "typescript", + code: `export class ItemComponent { + count = signal(0); + double = computed(() => this.count() * 2); + add() { this.count.set(this.count() + 1); } +} +// template: {{ double() }} +// count.set(1) -> double dirty -> this view flagged +// double recomputes lazily during the refresh: 1 * 2 = 2`, + }, + }, + { + question: "What changes when an app switches to provideZonelessChangeDetection()?", + answer: + "zone.js is no longer loaded, so finishing an async task does not trigger anything. Change detection runs only when Angular is told explicitly: a signal read in a template changes, a template event fires, markForCheck() is called, an async pipe emits, or ComponentRef.setInput() is used. Code that relied on a plain setTimeout or a manual property assignment to refresh the view stops working until it goes through one of those paths.", + }, + ], + relatedTopicIds: [ + "vue-reactivity", + "svelte-runes", + "render-cycle", + "memoization", + "fiber-tree", + ], }; diff --git a/src/content/theory/frameworks/svelte-runes.ts b/src/content/theory/frameworks/svelte-runes.ts index 01b3c87..8cc805f 100644 --- a/src/content/theory/frameworks/svelte-runes.ts +++ b/src/content/theory/frameworks/svelte-runes.ts @@ -1,11 +1,97 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const svelteRunesTheory: TopicTheoryContent = { - summary: "Svelte Runes theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], - relatedTopicIds: ["vue-reactivity", "angular-change-detection", "virtual-dom", "memoization"], + summary: + "Runes are the Svelte 5 reactivity primitives. $state creates a signal, $derived creates a lazy memoized computation, and $effect subscribes to whatever it reads, while the compiler turns each template expression into a direct DOM write with no virtual DOM in between.", + whatItIs: [ + "In Svelte 4, reactivity was tied to assignment inside a component: the compiler instrumented every `x = ...` statement it could see. That worked inside a .svelte file and broke as soon as state moved into a plain module or a function. Runes replace that with explicit primitives that behave the same everywhere: $state, $derived and $effect are compiler keywords that expand into calls to a small signal runtime.", + "A $state value is a source signal: an object holding the current value, a version number, and a list of reactions that read it. $derived creates a derived signal that stays unevaluated until something reads it, then caches the result and records which sources it touched. $effect creates an effect that runs after the DOM has been updated and re-runs whenever one of its dependencies changes. Dependencies are never declared; they are collected by tracking reads while the function runs.", + "The graph is push-pull. A write to a source pushes a dirty flag along its recorded edges: deriveds that read it become dirty, and reactions of those deriveds become maybe-dirty, since a derived may recompute to an equal value. Nothing recomputes at write time. A microtask flush then runs the queued effects, and each effect pulls fresh values as it reads them, which is what finally recomputes a dirty derived. Equal results stop propagation early.", + "The other half is the compiler. Because markup is static, Svelte knows at build time that a given text node depends on a given expression. It emits a template string that is cloned into real DOM once, then a template effect per dynamic expression that calls set_text or set_attribute on the exact node it owns. There is no tree of virtual nodes to build on every update and no diff to find what changed, because the dependency between signal and node was resolved before the code ran.", + ], + howItWorks: [ + "Step 1: the compiler rewrites `let count = $state(0)` to `let count = $.state(0)`, and every read of count to `$.get(count)` and every write to `$.set(count, ...)`. For objects and arrays, $state wraps the value in a Proxy that lazily creates one source per property on first read.", + "Step 2: markup becomes `$.template('')`. On mount the template is cloned with cloneNode, and the compiler emits `$.child` and `$.sibling` calls that grab references to the nodes that hold dynamic expressions.", + "Step 3: each dynamic expression becomes a `$.template_effect`. It runs immediately: reading a signal inside it registers the effect as a reaction of that signal, and reading an unread $derived computes it now and links it to the sources it touched. The effect writes the result into its node.", + "Step 4: user effects created with $effect are scheduled to run after mount, in a microtask, once the DOM is in place. They subscribe to whatever they read.", + "Step 5: a write such as `count++` compiles to `$.update(count)`. It bumps the source version, marks direct reactions dirty and reactions-of-deriveds maybe-dirty, and queues one flush. Multiple writes in the same tick share that flush.", + "Step 6: the flush runs template effects first, then user effects. A maybe-dirty effect first checks whether its derived dependencies actually changed version; if not, it is skipped. Each running effect re-collects its dependencies from scratch, so the graph always reflects the last run.", + ], + commonMistakes: [ + { + title: "Destructuring $state and expecting it to stay reactive", + explanation: + "`const { done } = todos[0]` copies the value out of the proxy. The compiler can only rewrite reads of the variable itself, so the copy is a plain boolean that never updates.", + fix: "Read through the proxy where the value is used, or wrap the read in $derived if you want a named reactive alias: `const done = $derived(todos[0].done)`.", + }, + { + title: "Mutating $state.raw and waiting for an update", + explanation: + "$state.raw stores the object without a Proxy, so `raw.n = 1` is an ordinary property write that no trap observes. The source is only written on reassignment.", + fix: "Reassign the whole value (`raw = { ...raw, n: 1 }`) or use plain $state when nested mutation needs to be tracked. Reserve $state.raw for large or immutable data.", + }, + { + title: "Using $effect to derive state", + explanation: + "Writing `$effect(() => { double = count * 2 })` works but runs after the DOM update, so the first render sees the stale value, and it turns a pure computation into a side effect that can chain into further flushes.", + fix: "Use $derived for values computed from other state. It is lazy, memoized, and consistent within the same flush. Keep $effect for real side effects such as logging, timers or imperative DOM APIs.", + }, + { + title: "Reading state after a write and expecting the DOM to match", + explanation: + "Signals update synchronously but effects, including template effects, run in a later microtask. Reading `element.textContent` right after `count++` returns the old text.", + fix: "Await `tick()` from svelte before reading the DOM, or move the DOM read into an $effect that depends on the same state.", + }, + ], + interviewQuestions: [ + { + question: "What is the difference between $state and $derived?", + answer: + "$state creates a source signal that you write to directly. $derived creates a derived signal computed from other signals; it is lazy, so the expression does not run until something reads it, and memoized, so repeated reads return the cached value until a dependency changes. You never assign to a $derived.", + codeExample: { + code: `let count = $state(0); +let double = $derived(count * 2); + +// double is not computed yet +count = 5; +console.log(double); // computes now, 10 +console.log(double); // cached, no recompute`, + }, + }, + { + question: "Why does Svelte 5 not need a virtual DOM?", + answer: + "The compiler sees the markup at build time, so it knows which text node or attribute depends on which expression. It emits one template effect per expression that writes directly to that node with set_text or set_attribute. A signal write marks only those effects dirty, so there is no need to rebuild a tree and diff it to discover what changed.", + }, + { + question: "What does push-pull mean in the signal graph?", + answer: + "A write pushes dirty flags along the recorded dependency edges without recomputing anything. When the flush runs an effect, the effect pulls values by reading them, and a dirty derived recomputes at that moment. This keeps writes cheap and avoids computing values nobody reads.", + }, + { + question: "When does an $effect run relative to the DOM update, and how does it know its dependencies?", + answer: + "Effects run in a microtask after template effects have updated the DOM, so DOM reads inside them see the new state. Dependencies are collected by tracking every signal read while the callback runs, and they are re-collected on each run, so conditional reads change the subscription set.", + codeExample: { + code: `let open = $state(false); +let name = $state('a'); + +$effect(() => { + // subscribes to name only while open is true + if (open) console.log(name); +});`, + }, + }, + { + question: "How does $state track nested writes like todos[0].done = true?", + answer: + "$state wraps objects and arrays in a Proxy. The get trap lazily creates a source signal per property the first time it is read and nests another proxy for object values. The set trap writes that per-property source, so only reactions that read todos[0].done are marked dirty; the array length and other items are unaffected.", + }, + ], + relatedTopicIds: [ + "vue-reactivity", + "angular-change-detection", + "virtual-dom", + "memoization", + ], }; diff --git a/src/content/theory/frameworks/vue-reactivity.ts b/src/content/theory/frameworks/vue-reactivity.ts index 4575755..a35072f 100644 --- a/src/content/theory/frameworks/vue-reactivity.ts +++ b/src/content/theory/frameworks/vue-reactivity.ts @@ -1,11 +1,94 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const vueReactivityTheory: TopicTheoryContent = { - summary: "Vue Reactivity theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "Vue's reactivity system intercepts property reads and writes with Proxy traps and ref accessors, records which effect read which property, and re-runs exactly those effects when the property changes.", + whatItIs: [ + "In Vue you mutate state directly: state.count++ or count.value++. There is no setter function to call and no dependency array to declare. The framework still knows what to update because every reactive object is a Proxy and every ref is an object with an accessor, so each read and write passes through code Vue controls.", + "That interception feeds two operations. During an effect's run, the get trap calls track(target, key), which adds the currently running effect to a Dep set stored under that target and key in a global WeakMap called targetMap. When a write reaches the set trap, trigger(target, key) looks up the same Dep and schedules every effect in it. The set of dependencies is rebuilt on every run, so a branch that stops reading a property also stops subscribing to it.", + "Effects come in a few forms with the same core. watchEffect and watch wrap a user callback. computed wraps a getter, caches the result, and flips a dirty flag on trigger instead of recomputing eagerly. A component's render function is also an effect, which is why a Vue component re-renders only when a property it actually read changes, rather than on every state update it owns as in React.", + "Triggered effects do not run inline by default. A scheduler pushes them into a job queue, deduplicates by identity, and flushes the queue in a single microtask. Several synchronous writes therefore produce one re-run. nextTick returns the promise that resolves after that flush. Vue 3.4 rewrote the internals around version counters so a computed can check whether any dependency changed without re-walking its subscriptions, and 3.5 lowered memory use further, but the track and trigger model is unchanged.", + ], + howItWorks: [ + "Step 1: reactive(obj) creates a Proxy with get, set, has, deleteProperty, and ownKeys handlers and caches it in a WeakMap so the same raw object always maps to the same proxy. ref(value) creates a RefImpl whose value accessor pair does the same job for a single slot, which is how primitives become observable.", + "Step 2: creating an effect (watchEffect, computed, a component render) wraps the function in a ReactiveEffect. Running it sets the global activeEffect to that instance for the duration of the call, so any interceptor that fires during the run can identify the reader.", + "Step 3: a get trap or ref getter calls track(target, key). If activeEffect is set, track finds or creates targetMap.get(raw).get(key), a Dep, and links the effect to it. If activeEffect is undefined, for example during a read at module scope or inside an event handler, nothing is recorded.", + "Step 4: a set trap or ref setter writes the new value, compares it with the old one using Object.is, and calls trigger(target, key) only when they differ. trigger collects the effects in that Dep. Adding or deleting a key also triggers effects that iterated the object, which is why ownKeys and has are trapped too.", + "Step 5: each collected effect is handed to its scheduler. A plain effect runs immediately, a computed is marked dirty and notifies its own subscribers, and a watchEffect or render effect is pushed into the job queue. queueJob ignores an effect that is already queued and schedules one flushJobs microtask if none is pending.", + "Step 6: flushJobs runs pre-flush jobs, then component updates sorted by component id so parents render before children, then post-flush jobs such as watchers with flush: 'post'. Each run resets activeEffect, re-executes the function, and rebuilds its dependency set, after which the nextTick promise resolves.", + ], + commonMistakes: [ + { + title: "Destructuring a reactive object", + explanation: + "const { total } = state reads state.total once through the get trap and stores the plain value in a local variable. The variable has no interceptor, so an effect that reads it records no dependency and never re-runs.", + fix: "Keep property access on the proxy inside the effect, or convert with toRefs(state) so each destructured binding is an ObjectRefImpl whose .value forwards to the proxy.", + }, + { + title: "Forgetting .value on a ref in script", + explanation: + "Templates and reactive() objects unwrap refs automatically, so it is easy to write count++ in a script block. That reassigns the local variable to NaN or a new number and discards the RefImpl entirely.", + fix: "Always read and write count.value in JavaScript. Use ref for primitives and reactive for objects you never reassign, and let the template compiler handle unwrapping.", + }, + { + title: "Expecting the DOM to update synchronously", + explanation: + "After state.count++ the DOM still shows the old value because the render effect is queued and runs in a microtask. Code that measures the DOM right after a write reads stale layout.", + fix: "await nextTick() before reading the DOM, or put the measurement in a watcher with flush: 'post'. Do not switch effects to flush: 'sync' just to make a test pass, that removes batching.", + }, + { + title: "Relying on a computed for side effects", + explanation: + "A computed getter runs lazily, only when something reads .value and a dependency changed. A side effect placed inside it may run late, several times, or never, depending on who reads the computed.", + fix: "Keep computed getters pure and use watch or watchEffect for side effects. If a value must be recalculated eagerly on change, a watcher with an explicit source expresses that intent.", + }, + ], + interviewQuestions: [ + { + question: "How does Vue know which effects to re-run when a property changes?", + answer: + "Every reactive object is a Proxy. While an effect runs, Vue sets a global activeEffect, and the get trap calls track(target, key) to add that effect to a Dep stored in targetMap under the object and key. The set trap calls trigger(target, key), which schedules every effect in that Dep. Dependencies are collected from actual reads, so there is no declaration step.", + codeExample: { + language: "javascript", + code: `const state = reactive({ count: 0, name: 'Ada' }) + +watchEffect(() => console.log(state.count)) +// get trap: track(state, 'count') -> Dep { effect } + +state.name = 'Bob' // set trap: no Dep for 'name', nothing runs +state.count = 1 // set trap: trigger(state, 'count') -> effect queued`, + }, + }, + { + question: "What is the difference between ref and reactive, and why do both exist?", + answer: + "reactive wraps an object in a Proxy, so it cannot hold a primitive and stops tracking if you reassign or destructure it. ref wraps any value in a RefImpl object with a value accessor, so primitives become observable and the wrapper can be passed around or returned from a composable without losing reactivity. Inside reactive objects and templates, refs are unwrapped automatically.", + codeExample: { + language: "javascript", + code: `const count = ref(0) // RefImpl { value: 0 } +count.value++ // setter -> triggerRefValue + +const state = reactive({ n: 1 }) +const { n } = state // n is a plain 1, not reactive +const { n: nRef } = toRefs(state) +nRef.value // forwards to state.n through the Proxy`, + }, + }, + { + question: "How does computed caching work, and when does the getter actually run?", + answer: + "computed creates a ComputedRefImpl with a dirty flag and a cached value. Reading .value runs the getter only when dirty is true, then caches the result and clears the flag. When a dependency triggers, the computed is marked dirty and its own subscribers are notified, but the getter is not re-run until someone reads .value again. Vue 3.4 backs this with version counters so an unchanged computed can return its cache without walking its dependencies.", + }, + { + question: "Why does the DOM not update immediately after a state mutation, and what does nextTick do?", + answer: + "Triggered render effects are pushed into a job queue rather than run inline. queueJob deduplicates by effect identity and schedules a single flushJobs microtask, so multiple synchronous writes produce one render. nextTick returns the promise chained after that flush, so awaiting it guarantees the DOM reflects the latest state.", + }, + { + question: "How does Vue's reactivity differ from React's re-render model?", + answer: + "React re-runs the whole component function when any of its state changes and then diffs the output, relying on memoization to skip children. Vue tracks reads at the property level, so a component's render effect re-runs only when a property it read changes, and unrelated state updates cost nothing. The trade-off is that Vue state must stay inside proxies or refs, and copying a value out of them silently breaks tracking.", + }, + ], relatedTopicIds: ["svelte-runes", "angular-change-detection", "render-cycle", "hooks", "closures"], }; From ec3516afd603cf920cc776552b84950bdfbb1a24 Mon Sep 17 00:00:00 2001 From: tornike14 Date: Fri, 18 Sep 2026 13:55:24 -0400 Subject: [PATCH 04/11] Add caching, JWT, and state batching topics --- .../components/CacheStorePanel.tsx | 73 ++ .../components/StatePanels.tsx | 95 ++ .../components/WriteQueue.tsx | 34 + .../visualizations/caching-strategies/data.ts | 823 +++++++++++++++++- .../caching-strategies/helpers.ts | 48 +- .../caching-strategies/index.tsx | 164 +++- .../caching-strategies/types.ts | 58 +- .../components/ClaimsTable.tsx | 38 + .../components/ServerStore.tsx | 44 + .../components/StatePanels.tsx | 108 +++ .../components/TokenParts.tsx | 67 ++ .../components/VerificationChecklist.tsx | 43 + .../visualizations/jwt-authentication/data.ts | 750 +++++++++++++++- .../jwt-authentication/helpers.ts | 84 +- .../jwt-authentication/index.tsx | 165 +++- .../jwt-authentication/types.ts | 68 +- .../components/EventLoopPanel.tsx | 69 ++ .../components/HookStatePanel.tsx | 55 ++ .../components/RenderLogPanel.tsx | 60 ++ .../components/UpdateQueuePanel.tsx | 48 + .../visualizations/state-batching/data.ts | 759 +++++++++++++++- .../visualizations/state-batching/helpers.ts | 73 +- .../visualizations/state-batching/index.tsx | 217 ++++- .../visualizations/state-batching/types.ts | 74 +- .../theory/backend/caching-strategies.ts | 114 ++- .../theory/backend/jwt-authentication.ts | 105 ++- src/content/theory/react/state-batching.ts | 99 ++- 27 files changed, 4289 insertions(+), 46 deletions(-) create mode 100644 src/components/visualizations/caching-strategies/components/CacheStorePanel.tsx create mode 100644 src/components/visualizations/caching-strategies/components/StatePanels.tsx create mode 100644 src/components/visualizations/caching-strategies/components/WriteQueue.tsx create mode 100644 src/components/visualizations/jwt-authentication/components/ClaimsTable.tsx create mode 100644 src/components/visualizations/jwt-authentication/components/ServerStore.tsx create mode 100644 src/components/visualizations/jwt-authentication/components/StatePanels.tsx create mode 100644 src/components/visualizations/jwt-authentication/components/TokenParts.tsx create mode 100644 src/components/visualizations/jwt-authentication/components/VerificationChecklist.tsx create mode 100644 src/components/visualizations/state-batching/components/EventLoopPanel.tsx create mode 100644 src/components/visualizations/state-batching/components/HookStatePanel.tsx create mode 100644 src/components/visualizations/state-batching/components/RenderLogPanel.tsx create mode 100644 src/components/visualizations/state-batching/components/UpdateQueuePanel.tsx diff --git a/src/components/visualizations/caching-strategies/components/CacheStorePanel.tsx b/src/components/visualizations/caching-strategies/components/CacheStorePanel.tsx new file mode 100644 index 0000000..3723826 --- /dev/null +++ b/src/components/visualizations/caching-strategies/components/CacheStorePanel.tsx @@ -0,0 +1,73 @@ +import { cn } from "@/lib/utils"; +import { ENTRY_LABELS, ENTRY_STYLES } from "../helpers"; +import type { CacheEntry, SourceRow } from "../types"; + +const entryFingerprint = (entry: CacheEntry) => + `${entry.key}|${entry.value}|${entry.ttl}|${entry.state}`; + +interface CacheStorePanelProps { + entries: CacheEntry[]; + database: SourceRow[]; + started: boolean; +} + +/** Key, value, TTL rows for the cache, with the database rows underneath for comparison. */ +export const CacheStorePanel = ({ entries, database, started }: CacheStorePanelProps) => { + if (!started) { + return ( +

    + waiting +

    + ); + } + + return ( +
    + {entries.length === 0 ? ( +

    + empty +

    + ) : ( +
      + {entries.map((entry) => ( +
    • + {entry.key} + {entry.value} + + ttl {entry.ttl} + + + {ENTRY_LABELS[entry.state]} + +
    • + ))} +
    + )} + + {database.length > 0 && ( +
    +

    + database (source of truth) +

    +
      + {database.map((row) => ( +
    • + {row.key} + {row.value} +
    • + ))} +
    +
    + )} +
    + ); +}; diff --git a/src/components/visualizations/caching-strategies/components/StatePanels.tsx b/src/components/visualizations/caching-strategies/components/StatePanels.tsx new file mode 100644 index 0000000..443a47a --- /dev/null +++ b/src/components/visualizations/caching-strategies/components/StatePanels.tsx @@ -0,0 +1,95 @@ +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { MessageFlow } from "@/components/visualization-ui/MessageFlow"; +import { MetricBars } from "@/components/visualization-ui/MetricBars"; +import { TokenChips } from "@/components/visualization-ui/TokenChips"; +import { ACTORS } from "../data"; +import type { CachingKind, CachingStep } from "../types"; +import { CacheStorePanel } from "./CacheStorePanel"; +import { WriteQueue } from "./WriteQueue"; + +interface StatePanelsProps { + kind: CachingKind; + step: CachingStep | null; + flashes: Record<"cache" | "messages" | "stats" | "queue" | "recency", boolean>; +} + +const Waiting = () => ( +

    + waiting +

    +); + +/** The topic-specific panels to the right of the source code. */ +export const StatePanels = ({ kind, step, flashes }: StatePanelsProps) => ( +
    + {kind === "lru" && ( + + {step ? ( + + ) : ( + + )} + + )} + +
    + + + + + + {step ? : } + +
    + + {kind === "write" && ( + + {step ? : } + + )} + + {kind !== "lru" && ( + + {step ? ( + + ) : ( + + )} + + )} +
    +); diff --git a/src/components/visualizations/caching-strategies/components/WriteQueue.tsx b/src/components/visualizations/caching-strategies/components/WriteQueue.tsx new file mode 100644 index 0000000..6b8e41b --- /dev/null +++ b/src/components/visualizations/caching-strategies/components/WriteQueue.tsx @@ -0,0 +1,34 @@ +import { cn } from "@/lib/utils"; +import { QUEUE_LABELS, QUEUE_STYLES } from "../helpers"; +import type { QueuedWrite } from "../types"; + +/** Pending database writes for the write-behind policy, oldest first. */ +export const WriteQueue = ({ items }: { items: QueuedWrite[] }) => { + if (items.length === 0) { + return ( +

    + nothing queued +

    + ); + } + + return ( +
      + {items.map((item, index) => ( +
    1. + {index} + {item.label} + + {QUEUE_LABELS[item.status]} + +
    2. + ))} +
    + ); +}; diff --git a/src/components/visualizations/caching-strategies/data.ts b/src/components/visualizations/caching-strategies/data.ts index cb0ff5c..7209fff 100644 --- a/src/components/visualizations/caching-strategies/data.ts +++ b/src/components/visualizations/caching-strategies/data.ts @@ -1 +1,822 @@ -export {}; +import type { FlowActor, FlowMessage } from "@/components/visualization-ui/MessageFlow"; +import type { MetricBar } from "@/components/visualization-ui/MetricBars"; +import type { TokenChip } from "@/components/visualization-ui/TokenChips"; +import type { CacheEntry, CachingExample, QueuedWrite, SourceRow } from "./types"; + +export const ACTORS: FlowActor[] = [ + { id: "app", label: "App" }, + { id: "cache", label: "Cache" }, + { id: "db", label: "Database" }, +]; + +/* ── shared builders ── */ + +const msg = ( + id: string, + from: string, + to: string, + label: string, + detail: string, + status: FlowMessage["status"], +): FlowMessage => ({ id, from, to, label, detail, status }); + +const latencyBar = (id: string, label: string, ms: number): MetricBar => ({ + id, + label, + value: ms / 125, + display: `${ms} ms`, + tone: ms > 50 ? "amber" : "green", +}); + +const countBar = ( + id: string, + label: string, + count: number, + max: number, + tone: MetricBar["tone"], +): MetricBar => ({ id, label, value: count / max, display: String(count), tone }); + +const asideStats = ( + hits: number, + misses: number, + latencyMs: number | null, + dbQueries: number, +): MetricBar[] => { + const total = hits + misses; + return [ + { + id: "ratio", + label: "hit ratio", + value: total === 0 ? 0 : hits / total, + display: total === 0 ? "0 / 0" : `${Math.round((hits / total) * 100)}% (${hits}/${total})`, + tone: "cyan", + }, + latencyMs === null + ? { id: "latency", label: "last read", value: 0, display: "n/a", tone: "slate" } + : latencyBar("latency", "last read", latencyMs), + countBar("db", "db queries", dbQueries, 4, "violet"), + ]; +}; + +const writeStats = ( + writeMs: number | null, + queued: number, + behind: number, +): MetricBar[] => [ + writeMs === null + ? { id: "wlat", label: "write latency", value: 0, display: "n/a", tone: "slate" } + : latencyBar("wlat", "write latency", writeMs), + countBar("queued", "queued writes", queued, 2, "amber"), + countBar("behind", "rows behind", behind, 2, behind > 0 ? "rose" : "green"), +]; + +const lruStats = ( + size: number, + hits: number, + misses: number, + evictions: number, +): MetricBar[] => [ + { id: "size", label: "size", value: size / 3, display: `${size} / 3`, tone: "cyan" }, + countBar("hits", "hits", hits, 3, "green"), + countBar("misses", "misses", misses, 3, "rose"), + countBar("evict", "evictions", evictions, 3, "violet"), +]; + +const user42 = (value: string, ttl: string, state: CacheEntry["state"]): CacheEntry => ({ + key: "user:42", + value, + ttl, + state, +}); + +const dbAda: SourceRow[] = [{ key: "users.id = 42", value: '{ name: "Ada" }' }]; +const dbAdaL: SourceRow[] = [{ key: "users.id = 42", value: '{ name: "Ada L." }' }]; + +const getMiss = (suffix: string): FlowMessage[] => [ + msg(`get-${suffix}`, "app", "cache", "GET user:42", "1 ms", "done"), + msg(`nil-${suffix}`, "cache", "app", "nil", "miss", "failed"), +]; + +const getHit = (suffix: string, value: string, wrong = false): FlowMessage[] => [ + msg(`get-${suffix}`, "app", "cache", "GET user:42", "1 ms", "done"), + msg(`hit-${suffix}`, "cache", "app", value, wrong ? "hit, stale" : "hit", wrong ? "failed" : "active"), +]; + +/* ── LRU builders ── */ + +const lruEntry = (key: string, value: number, state: CacheEntry["state"] = "fresh"): CacheEntry => ({ + key, + value: String(value), + ttl: "none", + state, +}); + +const recency = (order: [string, number][], evicted?: [string, number]): TokenChip[] => { + const chips: TokenChip[] = order.map(([key, value], index) => ({ + id: key, + label: `${key} = ${value}`, + value: index === 0 ? "most recent" : index === order.length - 1 ? "least recent" : undefined, + tone: index === 0 ? "active" : index === order.length - 1 ? "amber" : "neutral", + })); + if (evicted) { + chips.push({ id: evicted[0], label: `${evicted[0]} = ${evicted[1]}`, value: "evicted", tone: "miss" }); + } + return chips; +}; + +const queued = (id: string, label: string, status: QueuedWrite["status"]): QueuedWrite => ({ + id, + label, + status, +}); + +export const EXAMPLES: CachingExample[] = [ + /* ── Cache-aside ── */ + { + id: "aside", + title: "Cache-aside", + description: + "The application checks the cache first, falls back to the database on a miss, and fills the cache with a TTL. Shows expiry, a stale read after an update, and the invalidation fix.", + kind: "aside", + codeLines: [ + { num: 1, text: "async function getUser(id) {" }, + { num: 2, text: " const key = `user:${id}`;" }, + { num: 3, text: " const cached = await redis.get(key);" }, + { num: 4, text: " if (cached) return JSON.parse(cached);" }, + { num: 5, text: " const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);" }, + { num: 6, text: " await redis.set(key, JSON.stringify(row), 'EX', 60);" }, + { num: 7, text: " return row;" }, + { num: 8, text: "}" }, + { num: 9, text: "" }, + { num: 10, text: "async function updateUser(id, patch) {" }, + { num: 11, text: " await db.query('UPDATE users SET name = $1 WHERE id = $2', [patch.name, id]);" }, + { num: 12, text: " // missing: await redis.del(`user:${id}`);" }, + { num: 13, text: "}" }, + ], + steps: [ + { + descriptionHtml: + "getUser(42) builds the cache key user:42. The cache is a key-value copy that sits in front of the database, and the key is the only thing the two share, so it must be built the same way on every path.", + activeLine: 2, + doneLines: [1], + cache: [], + database: dbAda, + messages: [], + activeActorId: "app", + stats: asideStats(0, 0, null, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + 'redis.get answers in about 1 ms with nil: a miss. In cache-aside the application owns the lookup, the cache never talks to the database on its own.', + activeLine: 3, + doneLines: [1, 2], + cache: [], + database: dbAda, + messages: getMiss("1"), + activeActorId: "cache", + stats: asideStats(0, 1, null, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The if falls through because there is nothing to parse. A miss costs the cache round trip plus the full query, so a cold cache is slightly slower than no cache at all.", + activeLine: 4, + doneLines: [1, 2, 3], + cache: [], + database: dbAda, + messages: getMiss("1"), + activeActorId: "app", + stats: asideStats(0, 1, null, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The application queries Postgres, the source of truth. This is the 120 ms the cache exists to avoid: disk reads, query planning, and a network hop to a machine that every other request is also hitting.", + activeLine: 5, + doneLines: [1, 2, 3, 4], + cache: [], + database: dbAda, + messages: [ + ...getMiss("1"), + msg("sel-1", "app", "db", "SELECT ... WHERE id = 42", "120 ms", "active"), + msg("row-1", "db", "app", '{ name: "Ada" }', "1 row", "active"), + ], + activeActorId: "db", + stats: asideStats(0, 1, null, 1), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The row is serialized and stored with EX 60, a 60 second TTL. The TTL is a safety net: even if invalidation is forgotten later, this copy dies on its own within a minute.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5], + cache: [user42('{ name: "Ada" }', "60 s", "fresh")], + database: dbAda, + messages: [ + ...getMiss("1"), + msg("sel-1", "app", "db", "SELECT ... WHERE id = 42", "120 ms", "done"), + msg("row-1", "db", "app", '{ name: "Ada" }', "1 row", "done"), + msg("set-1", "app", "cache", "SET user:42 EX 60", "1 ms", "active"), + ], + activeActorId: "cache", + stats: asideStats(0, 1, null, 1), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The caller gets the row after about 122 ms. Nothing was saved on this request. The payoff is deferred to every later call that finds the key.", + activeLine: 7, + doneLines: [1, 2, 3, 4, 5, 6], + cache: [user42('{ name: "Ada" }', "60 s", "fresh")], + database: dbAda, + messages: [ + ...getMiss("1"), + msg("sel-1", "app", "db", "SELECT ... WHERE id = 42", "120 ms", "done"), + msg("row-1", "db", "app", '{ name: "Ada" }', "1 row", "done"), + msg("set-1", "app", "cache", "SET user:42 EX 60", "1 ms", "done"), + ], + activeActorId: "app", + stats: asideStats(0, 1, 122, 1), + queue: [], + recency: [], + }, + { + descriptionHtml: + 'Five seconds later a second getUser(42) finds the key: a hit with 55 s of TTL left. Redis serves it from memory in about 1 ms.', + activeLine: 3, + doneLines: [1, 2], + cache: [user42('{ name: "Ada" }', "55 s", "hit")], + database: dbAda, + messages: getHit("2", '{ name: "Ada" }'), + activeActorId: "cache", + stats: asideStats(1, 1, 122, 1), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The parsed object returns in about 2 ms, sixty times faster than the miss, and the database never saw the request. Caches buy two things: lower latency for the caller and lower load on the source.", + activeLine: 4, + doneLines: [1, 2, 3], + cache: [user42('{ name: "Ada" }', "55 s", "hit")], + database: dbAda, + messages: [ + msg("get-2", "app", "cache", "GET user:42", "1 ms", "done"), + msg("hit-2", "cache", "app", '{ name: "Ada" }', "hit", "done"), + ], + activeActorId: "app", + stats: asideStats(1, 1, 2, 1), + queue: [], + recency: [], + }, + { + descriptionHtml: + "At t = 60 s the key expires and Redis drops it. If hundreds of requests arrive in that same instant they all miss and all run the same query, the thundering herd. Mitigations: a per-key lock so one caller refills, jittered TTLs, or serving the stale value while one request revalidates.", + activeLine: null, + doneLines: [1, 2, 3, 4], + cache: [user42('{ name: "Ada" }', "expired", "expired")], + database: dbAda, + messages: [], + activeActorId: "cache", + stats: asideStats(1, 1, 2, 1), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The next call at t = 61 s misses, queries, and refills the key for another 60 s. The hit ratio after three reads is 33%, which is why cache effectiveness is judged over a window of traffic, not per request.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5], + cache: [user42('{ name: "Ada" }', "60 s", "fresh")], + database: dbAda, + messages: [ + ...getMiss("3"), + msg("sel-3", "app", "db", "SELECT ... WHERE id = 42", "120 ms", "done"), + msg("row-3", "db", "app", '{ name: "Ada" }', "1 row", "done"), + msg("set-3", "app", "cache", "SET user:42 EX 60", "1 ms", "active"), + ], + activeActorId: "cache", + stats: asideStats(1, 2, 122, 2), + queue: [], + recency: [], + }, + { + descriptionHtml: + "updateUser(42, { name: 'Ada L.' }) writes the new name straight to Postgres. The database now says Ada L. while the cache still holds Ada with 58 s left. The two copies have diverged.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 10], + cache: [user42('{ name: "Ada" }', "58 s", "stale")], + database: dbAdaL, + messages: [ + msg("upd-1", "app", "db", "UPDATE users SET name = 'Ada L.'", "120 ms", "active"), + msg("ok-1", "db", "app", "UPDATE 1", "committed", "active"), + ], + activeActorId: "db", + stats: asideStats(1, 2, 122, 3), + queue: [], + recency: [], + }, + { + descriptionHtml: + 'Line 12 never ran, so the next read hits the cache and returns Ada: a stale read, fast and wrong. Invalidation is the hard problem because the code that writes has to know every key the write affects.', + activeLine: 4, + doneLines: [1, 2, 3, 10, 11], + cache: [user42('{ name: "Ada" }', "57 s", "stale")], + database: dbAdaL, + messages: getHit("4", '{ name: "Ada" }', true), + activeActorId: "app", + stats: asideStats(2, 2, 2, 3), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The fix is one line after the UPDATE: await redis.del('user:42'). Deleting instead of overwriting is safer, because the next reader rebuilds the entry from the row the database actually has.", + activeLine: 12, + doneLines: [1, 2, 3, 4, 10, 11], + cache: [], + database: dbAdaL, + messages: [ + msg("upd-1", "app", "db", "UPDATE users SET name = 'Ada L.'", "120 ms", "done"), + msg("del-1", "app", "cache", "DEL user:42", "1 ms", "active"), + ], + activeActorId: "cache", + stats: asideStats(2, 2, 2, 3), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The following read misses once, fetches Ada L., and caches it. HTTP caching works the same way one layer out: Cache-Control: max-age is the TTL, and an ETag revalidation is the check that a cached copy still matches the origin.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5, 10, 11, 12], + cache: [user42('{ name: "Ada L." }', "60 s", "fresh")], + database: dbAdaL, + messages: [ + ...getMiss("5"), + msg("sel-5", "app", "db", "SELECT ... WHERE id = 42", "120 ms", "done"), + msg("row-5", "db", "app", '{ name: "Ada L." }', "1 row", "done"), + msg("set-5", "app", "cache", "SET user:42 EX 60", "1 ms", "active"), + ], + activeActorId: "cache", + stats: asideStats(2, 3, 122, 4), + queue: [], + recency: [], + }, + ], + }, + + /* ── Write-through and write-behind ── */ + { + id: "write", + title: "Write-through and write-behind", + description: + "Write-through updates the database and the cache on every write. Write-behind updates the cache and queues the database write, trading durability for speed.", + kind: "write", + codeLines: [ + { num: 1, text: "// write-through: database and cache change together" }, + { num: 2, text: "async function saveUserThrough(id, user) {" }, + { num: 3, text: " await db.query('UPDATE users SET name = $1 WHERE id = $2', [user.name, id]);" }, + { num: 4, text: " await redis.set(`user:${id}`, JSON.stringify(user));" }, + { num: 5, text: "}" }, + { num: 6, text: "" }, + { num: 7, text: "// write-behind: cache changes now, database catches up later" }, + { num: 8, text: "const pending = [];" }, + { num: 9, text: "async function saveUserBehind(id, user) {" }, + { num: 10, text: " await redis.set(`user:${id}`, JSON.stringify(user));" }, + { num: 11, text: " pending.push({ id, user });" }, + { num: 12, text: "}" }, + { num: 13, text: "setInterval(async () => {" }, + { num: 14, text: " const batch = pending.splice(0, 100);" }, + { num: 15, text: " for (const w of batch) {" }, + { num: 16, text: " await db.query('UPDATE users SET name = $1 WHERE id = $2', [w.user.name, w.id]);" }, + { num: 17, text: " }" }, + { num: 18, text: "}, 1000);" }, + ], + steps: [ + { + descriptionHtml: + "saveUserThrough(42, { name: 'Ada L.' }) writes the database first. Ordering matters: if the process dies between lines 3 and 4 the cache is stale but the write survived, the reverse order could lose the write entirely.", + activeLine: 3, + doneLines: [1, 2], + cache: [user42('{ name: "Ada" }', "none", "stale")], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [ + msg("wt-upd", "app", "db", "UPDATE users SET name = 'Ada L.'", "120 ms", "active"), + msg("wt-ok", "db", "app", "UPDATE 1", "committed", "active"), + ], + activeActorId: "db", + stats: writeStats(null, 0, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The cache is overwritten with the same object. For the 120 ms the query took, a concurrent reader would have seen Ada from the cache while the database already said Ada L., a short window but not zero.", + activeLine: 4, + doneLines: [1, 2, 3], + cache: [user42('{ name: "Ada L." }', "none", "fresh")], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [ + msg("wt-upd", "app", "db", "UPDATE users SET name = 'Ada L.'", "120 ms", "done"), + msg("wt-ok", "db", "app", "UPDATE 1", "committed", "done"), + msg("wt-set", "app", "cache", "SET user:42", "1 ms", "active"), + ], + activeActorId: "cache", + stats: writeStats(null, 0, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The write returns after about 122 ms, and the cache is never cold for this key. The costs: every write pays database latency plus a cache write, and rows that are written but never read still take up cache memory.", + activeLine: 5, + doneLines: [1, 2, 3, 4], + cache: [user42('{ name: "Ada L." }', "none", "fresh")], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [ + msg("wt-upd", "app", "db", "UPDATE users SET name = 'Ada L.'", "120 ms", "done"), + msg("wt-ok", "db", "app", "UPDATE 1", "committed", "done"), + msg("wt-set", "app", "cache", "SET user:42", "1 ms", "done"), + ], + activeActorId: "app", + stats: writeStats(122, 0, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + 'A read of user:42 through the cache-aside path is a hit and returns Ada L.. Write-through keeps the cache and the database in agreement at the cost of slower writes.', + activeLine: null, + doneLines: [1, 2, 3, 4, 5], + cache: [user42('{ name: "Ada L." }', "none", "hit")], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: getHit("wt", '{ name: "Ada L." }'), + activeActorId: "cache", + stats: writeStats(122, 0, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + "saveUserBehind(7, { name: 'Grace H.' }) touches only the cache. Postgres still has Grace. The cache is now ahead of the source of truth, which is the opposite of the stale-read problem.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 7, 8, 9], + cache: [ + user42('{ name: "Ada L." }', "none", "fresh"), + { key: "user:7", value: '{ name: "Grace H." }', ttl: "none", state: "pending" }, + ], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [msg("wb-set-7", "app", "cache", "SET user:7", "1 ms", "active")], + activeActorId: "cache", + stats: writeStats(122, 0, 1), + queue: [], + recency: [], + }, + { + descriptionHtml: + "The write is appended to pending and the function returns after about 2 ms, sixty times faster than write-through. The caller has been told the save succeeded before any durable store has seen it.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 7, 8, 9, 10], + cache: [ + user42('{ name: "Ada L." }', "none", "fresh"), + { key: "user:7", value: '{ name: "Grace H." }', ttl: "none", state: "pending" }, + ], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [msg("wb-set-7", "app", "cache", "SET user:7", "1 ms", "done")], + activeActorId: "app", + stats: writeStats(2, 1, 1), + queue: [queued("w7", "UPDATE users SET name = 'Grace H.' WHERE id = 7", "pending")], + recency: [], + }, + { + descriptionHtml: + "saveUserBehind(9, { name: 'Linus T.' }) queues a second write. Anything that reads Postgres directly, a report, another service, a replica, sees two old names, and the cache must not evict these keys before they flush.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 7, 8, 9, 10], + cache: [ + user42('{ name: "Ada L." }', "none", "fresh"), + { key: "user:7", value: '{ name: "Grace H." }', ttl: "none", state: "pending" }, + { key: "user:9", value: '{ name: "Linus T." }', ttl: "none", state: "pending" }, + ], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [ + msg("wb-set-7", "app", "cache", "SET user:7", "1 ms", "done"), + msg("wb-set-9", "app", "cache", "SET user:9", "1 ms", "done"), + ], + activeActorId: "app", + stats: writeStats(2, 2, 2), + queue: [ + queued("w7", "UPDATE users SET name = 'Grace H.' WHERE id = 7", "pending"), + queued("w9", "UPDATE users SET name = 'Linus T.' WHERE id = 9", "pending"), + ], + recency: [], + }, + { + descriptionHtml: + "If the process crashes now, both queued writes vanish: the cache says Grace H., the database says Grace, and once the cache restarts even that trace is gone. Production write-behind keeps the queue in something durable, such as Redis Streams or Kafka.", + activeLine: null, + doneLines: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12], + cache: [ + user42('{ name: "Ada L." }', "none", "fresh"), + { key: "user:7", value: '{ name: "Grace H." }', ttl: "none", state: "pending" }, + { key: "user:9", value: '{ name: "Linus T." }', ttl: "none", state: "pending" }, + ], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [ + msg("wb-set-7", "app", "cache", "SET user:7", "1 ms", "done"), + msg("wb-set-9", "app", "cache", "SET user:9", "1 ms", "done"), + ], + activeActorId: "app", + stats: writeStats(2, 2, 2), + queue: [ + queued("w7", "UPDATE users SET name = 'Grace H.' WHERE id = 7", "pending"), + queued("w9", "UPDATE users SET name = 'Linus T.' WHERE id = 9", "pending"), + ], + recency: [], + }, + { + descriptionHtml: + "The interval fires after 1000 ms and splice takes up to 100 queued writes as one batch. Batching is the second benefit of write-behind: a burst of updates to the same row can collapse into a single database write.", + activeLine: 14, + doneLines: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13], + cache: [ + user42('{ name: "Ada L." }', "none", "fresh"), + { key: "user:7", value: '{ name: "Grace H." }', ttl: "none", state: "pending" }, + { key: "user:9", value: '{ name: "Linus T." }', ttl: "none", state: "pending" }, + ], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace" }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [], + activeActorId: "app", + stats: writeStats(2, 2, 2), + queue: [ + queued("w7", "UPDATE users SET name = 'Grace H.' WHERE id = 7", "active"), + queued("w9", "UPDATE users SET name = 'Linus T.' WHERE id = 9", "active"), + ], + recency: [], + }, + { + descriptionHtml: + "The first UPDATE lands and row 7 is durable. The caller that wrote it moved on a second ago, which is why write-behind is chosen for counters, view tallies, and session touches rather than payments.", + activeLine: 16, + doneLines: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], + cache: [ + user42('{ name: "Ada L." }', "none", "fresh"), + { key: "user:7", value: '{ name: "Grace H." }', ttl: "none", state: "fresh" }, + { key: "user:9", value: '{ name: "Linus T." }', ttl: "none", state: "pending" }, + ], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace H." }' }, + { key: "users.id = 9", value: '{ name: "Linus" }' }, + ], + messages: [ + msg("fl-7", "app", "db", "UPDATE ... WHERE id = 7", "120 ms", "done"), + msg("fl-9", "app", "db", "UPDATE ... WHERE id = 9", "120 ms", "active"), + ], + activeActorId: "db", + stats: writeStats(2, 1, 1), + queue: [ + queued("w7", "UPDATE users SET name = 'Grace H.' WHERE id = 7", "done"), + queued("w9", "UPDATE users SET name = 'Linus T.' WHERE id = 9", "active"), + ], + recency: [], + }, + { + descriptionHtml: + "The batch drains and the database catches up with the cache. Write-through pays 122 ms per write for agreement at all times. Write-behind pays 2 ms and accepts a window where the only copy of the data lives in a queue.", + activeLine: 17, + doneLines: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + cache: [ + user42('{ name: "Ada L." }', "none", "fresh"), + { key: "user:7", value: '{ name: "Grace H." }', ttl: "none", state: "fresh" }, + { key: "user:9", value: '{ name: "Linus T." }', ttl: "none", state: "fresh" }, + ], + database: [ + { key: "users.id = 42", value: '{ name: "Ada L." }' }, + { key: "users.id = 7", value: '{ name: "Grace H." }' }, + { key: "users.id = 9", value: '{ name: "Linus T." }' }, + ], + messages: [ + msg("fl-7", "app", "db", "UPDATE ... WHERE id = 7", "120 ms", "done"), + msg("fl-9", "app", "db", "UPDATE ... WHERE id = 9", "120 ms", "done"), + ], + activeActorId: "db", + stats: writeStats(2, 0, 0), + queue: [ + queued("w7", "UPDATE users SET name = 'Grace H.' WHERE id = 7", "done"), + queued("w9", "UPDATE users SET name = 'Linus T.' WHERE id = 9", "done"), + ], + recency: [], + }, + ], + }, + + /* ── LRU eviction ── */ + { + id: "lru", + title: "LRU eviction", + description: + "A cache with capacity 3 built on a Map. Reads move a key to the most recent position, and a full cache evicts the least recently used key.", + kind: "lru", + codeLines: [ + { num: 1, text: "class LRUCache {" }, + { num: 2, text: " constructor(capacity) {" }, + { num: 3, text: " this.capacity = capacity;" }, + { num: 4, text: " this.map = new Map(); // iterates in insertion order" }, + { num: 5, text: " }" }, + { num: 6, text: " get(key) {" }, + { num: 7, text: " if (!this.map.has(key)) return undefined;" }, + { num: 8, text: " const value = this.map.get(key);" }, + { num: 9, text: " this.map.delete(key);" }, + { num: 10, text: " this.map.set(key, value); // re-insert: now the newest" }, + { num: 11, text: " return value;" }, + { num: 12, text: " }" }, + { num: 13, text: " set(key, value) {" }, + { num: 14, text: " if (this.map.has(key)) this.map.delete(key);" }, + { num: 15, text: " else if (this.map.size >= this.capacity) {" }, + { num: 16, text: " this.map.delete(this.map.keys().next().value); // oldest" }, + { num: 17, text: " }" }, + { num: 18, text: " this.map.set(key, value);" }, + { num: 19, text: " }" }, + { num: 20, text: "}" }, + { num: 21, text: "const cache = new LRUCache(3);" }, + ], + steps: [ + { + descriptionHtml: + "new LRUCache(3) creates an empty Map. A bounded cache has to pick a victim when it is full, and LRU bets that a key nobody touched recently will not be touched soon. The Map trick: its first key is always the oldest insertion.", + activeLine: 4, + doneLines: [1, 2, 3, 21], + cache: [], + database: [], + messages: [], + stats: lruStats(0, 0, 0, 0), + queue: [], + recency: [], + }, + { + descriptionHtml: + "cache.set('a', 1): the key is new and the map is below capacity, so both guards are skipped and a is inserted. It is the only entry, so it is both the newest and the oldest.", + activeLine: 18, + doneLines: [1, 2, 3, 4, 5, 13, 14, 15, 21], + cache: [lruEntry("a", 1)], + database: [], + messages: [], + stats: lruStats(1, 0, 0, 0), + queue: [], + recency: recency([["a", 1]]), + }, + { + descriptionHtml: + "cache.set('b', 2) appends to the end of the map. The recency list is read newest first: b then a. No pointers are maintained by hand, the insertion order is the recency order.", + activeLine: 18, + doneLines: [1, 2, 3, 4, 5, 13, 14, 15, 21], + cache: [lruEntry("a", 1), lruEntry("b", 2)], + database: [], + messages: [], + stats: lruStats(2, 0, 0, 0), + queue: [], + recency: recency([["b", 2], ["a", 1]]), + }, + { + descriptionHtml: + "cache.set('c', 3) fills the cache to its capacity of 3. From now on every insert of a new key must evict something, and a is the current candidate.", + activeLine: 18, + doneLines: [1, 2, 3, 4, 5, 13, 14, 15, 21], + cache: [lruEntry("a", 1), lruEntry("b", 2), lruEntry("c", 3)], + database: [], + messages: [], + stats: lruStats(3, 0, 0, 0), + queue: [], + recency: recency([["c", 3], ["b", 2], ["a", 1]]), + }, + { + descriptionHtml: + 'cache.get(\'a\') is a hit. The entry is deleted and re-inserted, which moves it to the end of the map. a is now the newest and b has become the least recently used.', + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 21], + cache: [lruEntry("a", 1, "hit"), lruEntry("b", 2), lruEntry("c", 3)], + database: [], + messages: [], + stats: lruStats(3, 1, 0, 0), + queue: [], + recency: recency([["a", 1], ["c", 3], ["b", 2]]), + }, + { + descriptionHtml: + "The value 1 is returned. A read in an LRU cache is not free of side effects: touching a key protects it from eviction, and a scan that reads every key once can flush the genuinely hot entries.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 13, 21], + cache: [lruEntry("a", 1, "hit"), lruEntry("b", 2), lruEntry("c", 3)], + database: [], + messages: [], + stats: lruStats(3, 1, 0, 0), + queue: [], + recency: recency([["a", 1], ["c", 3], ["b", 2]]), + }, + { + descriptionHtml: + 'cache.set(\'d\', 4): the key is new and the size equals the capacity, so the oldest key is evicted. this.map.keys().next().value is b, the entry nothing has touched since it was written. It is deleted.', + activeLine: 16, + doneLines: [1, 2, 3, 4, 5, 13, 14, 15, 21], + cache: [lruEntry("a", 1), lruEntry("b", 2, "evicted"), lruEntry("c", 3)], + database: [], + messages: [], + stats: lruStats(2, 1, 0, 1), + queue: [], + recency: recency([["a", 1], ["c", 3]], ["b", 2]), + }, + { + descriptionHtml: + "d is inserted at the end and the cache is full again. Both the eviction and the insert are O(1), which is the same complexity a hand-written doubly linked list plus hash map would give.", + activeLine: 18, + doneLines: [1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 21], + cache: [lruEntry("a", 1), lruEntry("c", 3), lruEntry("d", 4)], + database: [], + messages: [], + stats: lruStats(3, 1, 0, 1), + queue: [], + recency: recency([["d", 4], ["a", 1], ["c", 3]]), + }, + { + descriptionHtml: + 'cache.get(\'b\') is a miss and returns undefined. In a cache-aside setup this miss becomes a database query. If b turns out to be hot, the capacity is too small or LRU is the wrong policy for this workload.', + activeLine: 7, + doneLines: [1, 2, 3, 4, 5, 6, 13, 21], + cache: [lruEntry("a", 1), lruEntry("c", 3), lruEntry("d", 4)], + database: [], + messages: [], + stats: lruStats(3, 1, 1, 1), + queue: [], + recency: recency([["d", 4], ["a", 1], ["c", 3]]), + }, + { + descriptionHtml: + 'cache.get(\'c\') is a hit and moves c to the front. a drops to the least recent position even though it was read earlier, because recency only counts the last touch.', + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 21], + cache: [lruEntry("a", 1), lruEntry("c", 3, "hit"), lruEntry("d", 4)], + database: [], + messages: [], + stats: lruStats(3, 2, 1, 1), + queue: [], + recency: recency([["c", 3], ["d", 4], ["a", 1]]), + }, + { + descriptionHtml: + "cache.set('e', 5) evicts a. Redis does the same job approximately: maxmemory-policy allkeys-lru samples five keys and evicts the least recently used of those instead of tracking a full list, and allkeys-lfu counts frequency for workloads where one-off keys would otherwise flush the hot set.", + activeLine: 18, + doneLines: [1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 21], + cache: [lruEntry("c", 3), lruEntry("d", 4), lruEntry("e", 5)], + database: [], + messages: [], + stats: lruStats(3, 2, 1, 2), + queue: [], + recency: recency([["e", 5], ["c", 3], ["d", 4]], ["a", 1]), + }, + ], + }, +]; diff --git a/src/components/visualizations/caching-strategies/helpers.ts b/src/components/visualizations/caching-strategies/helpers.ts index cb0ff5c..9258c65 100644 --- a/src/components/visualizations/caching-strategies/helpers.ts +++ b/src/components/visualizations/caching-strategies/helpers.ts @@ -1 +1,47 @@ -export {}; +import { + createKindBadgeClass, + createKindLabel, +} from "@/lib/visualization-helpers"; +import type { CacheEntryState, CachingKind, WriteStatus } from "./types"; + +export const kindBadgeClass = createKindBadgeClass({ + aside: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25", + write: "bg-amber-500/15 text-amber-400 border-amber-500/25", + lru: "bg-violet-500/15 text-violet-400 border-violet-500/25", +}); + +export const kindLabel = createKindLabel({ + aside: "cache-aside", + write: "write policies", + lru: "lru eviction", +}); + +export const ENTRY_STYLES: Record = { + fresh: "border-cyan-300/40 bg-cyan-400/10 text-cyan-100", + hit: "border-emerald-300/40 bg-emerald-400/10 text-emerald-100", + stale: "border-rose-400/40 bg-rose-400/10 text-rose-100", + expired: "border-slate-700/40 bg-slate-900/40 text-slate-500 line-through", + pending: "border-amber-300/40 bg-amber-400/10 text-amber-100", + evicted: "border-slate-700/40 bg-slate-900/40 text-slate-500 line-through", +}; + +export const ENTRY_LABELS: Record = { + fresh: "fresh", + hit: "hit", + stale: "stale", + expired: "expired", + pending: "not in db", + evicted: "evicted", +}; + +export const QUEUE_STYLES: Record = { + pending: "border-amber-300/40 bg-amber-400/10 text-amber-100", + active: "border-cyan-300/50 bg-cyan-400/12 text-cyan-100", + done: "border-emerald-300/40 bg-emerald-400/10 text-emerald-100", +}; + +export const QUEUE_LABELS: Record = { + pending: "queued", + active: "flushing", + done: "durable", +}; diff --git a/src/components/visualizations/caching-strategies/index.tsx b/src/components/visualizations/caching-strategies/index.tsx index c462848..0dac560 100644 --- a/src/components/visualizations/caching-strategies/index.tsx +++ b/src/components/visualizations/caching-strategies/index.tsx @@ -1,14 +1,166 @@ "use client"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { + CodeBlock, + type CodeBlockLine, +} from "@/components/visualization-ui/CodeBlock"; +import { TransportControls } from "@/components/visualization-ui/TransportControls"; import { ExampleSelector } from "@/components/visualization-ui/ExampleSelector"; +import { ToolbarPortal } from "@/components/layout/ToolbarPortal"; +import { cn } from "@/lib/utils"; +import { + VISUALIZATION_PANEL_TITLES, + VISUALIZATION_EMPTY_STATES, +} from "@/lib/visualization/uiCopy"; +import { useStepPlayback } from "@/hooks/useStepPlayback"; +import { useChangeFlash } from "@/hooks/useChangeFlash"; +import { EXAMPLES } from "./data"; +import { kindBadgeClass, kindLabel } from "./helpers"; +import { StatePanels } from "./components/StatePanels"; -// STUB: replaced by the real implementation. Keeps the build green while -// topics are authored in parallel. export const CachingStrategies = () => { - void ExampleSelector; + const [activeExampleId, setActiveExampleId] = useState(EXAMPLES[0].id); + + const handleExampleChange = (id: string) => { + setActiveExampleId(id); + }; + + const example = + EXAMPLES.find((e) => e.id === activeExampleId) ?? EXAMPLES[0]; + + const { + currentStepIndex, + isPlaying, + speedLevel, + speedLabel, + canStep, + canStepBack, + togglePlay, + step: handleStep, + stepBack: handleStepBack, + reset: handleReset, + setSpeedLevel, + jumpTo, + } = useStepPlayback({ + totalSteps: example.steps.length, + initialStep: -1, + resetKey: activeExampleId, + }); + + const currentStep = + currentStepIndex >= 0 ? example.steps[currentStepIndex] : null; + + const flashes = useChangeFlash( + { + description: currentStep?.descriptionHtml, + cache: [currentStep?.cache, currentStep?.database], + messages: [currentStep?.messages, currentStep?.activeActorId], + stats: currentStep?.stats, + queue: currentStep?.queue, + recency: currentStep?.recency, + }, + currentStepIndex, + ); + return ( -

    - Caching Strategies visualization coming soon. -

    + <> + +
    +
    +
    + ( + + {kindLabel(ex.kind)} + + )} + /> + + {kindLabel(example.kind)} + +
    + + +
    + +
    + {currentStep?.descriptionHtml ? ( +

    + ) : ( +

    + {VISUALIZATION_EMPTY_STATES.stepDescription} +

    + )} +
    +
    +
    + +
    +
    + + { + const isActive = currentStep?.activeLine === line.num; + const isDone = + currentStep?.doneLines.includes(line.num) ?? false; + return { + key: line.num, + lineNumber: line.num, + text: line.text, + className: cn( + isActive && "is-active", + isDone && !isActive && "is-done", + ), + }; + })} + /> + + + +
    +
    + ); }; diff --git a/src/components/visualizations/caching-strategies/types.ts b/src/components/visualizations/caching-strategies/types.ts index cb0ff5c..9110cdb 100644 --- a/src/components/visualizations/caching-strategies/types.ts +++ b/src/components/visualizations/caching-strategies/types.ts @@ -1 +1,57 @@ -export {}; +import type { SourceLine } from "@/types/visualization"; +import type { ExampleOption } from "@/components/visualization-ui/ExampleSelector"; +import type { FlowMessage } from "@/components/visualization-ui/MessageFlow"; +import type { MetricBar } from "@/components/visualization-ui/MetricBars"; +import type { TokenChip } from "@/components/visualization-ui/TokenChips"; + +export type CachingKind = "aside" | "write" | "lru"; + +/** Visual state of a single cache entry. */ +export type CacheEntryState = + | "fresh" + | "hit" + | "stale" + | "expired" + | "pending" + | "evicted"; + +export interface CacheEntry { + key: string; + value: string; + /** Remaining TTL as display text, for example "55 s" or "none". */ + ttl: string; + state: CacheEntryState; +} + +/** A row in the source of truth, shown under the cache so drift is visible. */ +export interface SourceRow { + key: string; + value: string; +} + +export type WriteStatus = "pending" | "active" | "done"; + +export interface QueuedWrite { + id: string; + label: string; + status: WriteStatus; +} + +export interface CachingStep { + descriptionHtml: string; + activeLine: number | null; + doneLines: number[]; + cache: CacheEntry[]; + database: SourceRow[]; + messages: FlowMessage[]; + activeActorId?: string; + stats: MetricBar[]; + queue: QueuedWrite[]; + recency: TokenChip[]; +} + +export interface CachingExample extends ExampleOption { + kind: CachingKind; + codeLines: SourceLine[]; + steps: CachingStep[]; +} diff --git a/src/components/visualizations/jwt-authentication/components/ClaimsTable.tsx b/src/components/visualizations/jwt-authentication/components/ClaimsTable.tsx new file mode 100644 index 0000000..419a3a9 --- /dev/null +++ b/src/components/visualizations/jwt-authentication/components/ClaimsTable.tsx @@ -0,0 +1,38 @@ +import { cn } from "@/lib/utils"; +import { CLAIM_ROW_STYLES } from "../helpers"; +import type { ClaimRow } from "../types"; + +export const ClaimsTable = ({ claims }: { claims: ClaimRow[] }) => { + if (claims.length === 0) { + return ( +

    + no claims yet +

    + ); + } + + return ( +
    +
    + claim + value + meaning +
    + {claims.map((row) => ( +
    + {row.claim} + {row.value} + + {row.meaning} + +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/jwt-authentication/components/ServerStore.tsx b/src/components/visualizations/jwt-authentication/components/ServerStore.tsx new file mode 100644 index 0000000..40a8503 --- /dev/null +++ b/src/components/visualizations/jwt-authentication/components/ServerStore.tsx @@ -0,0 +1,44 @@ +import { cn } from "@/lib/utils"; +import { STORE_STYLES } from "../helpers"; +import type { StoreEntry } from "../types"; + +export const ServerStore = ({ entries }: { entries: StoreEntry[] }) => { + if (entries.length === 0) { + return ( +

    + allow-list empty +

    + ); + } + + return ( +
    +
    + sha256(refresh token) + user + status +
    + {entries.map((entry) => ( +
    + + {entry.tokenHash} + + expires {entry.expires} + + + {entry.userId} + + {entry.status} + +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/jwt-authentication/components/StatePanels.tsx b/src/components/visualizations/jwt-authentication/components/StatePanels.tsx new file mode 100644 index 0000000..aa07305 --- /dev/null +++ b/src/components/visualizations/jwt-authentication/components/StatePanels.tsx @@ -0,0 +1,108 @@ +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { MessageFlow } from "@/components/visualization-ui/MessageFlow"; +import { MetricBars } from "@/components/visualization-ui/MetricBars"; +import type { JwtKind, JwtStep } from "../types"; +import { TokenParts } from "./TokenParts"; +import { ClaimsTable } from "./ClaimsTable"; +import { VerificationChecklist } from "./VerificationChecklist"; +import { ServerStore } from "./ServerStore"; + +const ACTORS = [ + { id: "client", label: "Client" }, + { id: "server", label: "Server" }, +]; + +const REFRESH_ACTORS = [ + { id: "client", label: "Client" }, + { id: "server", label: "Server" }, + { id: "db", label: "DB" }, +]; + +export type JwtFlashes = Record< + "tokenParts" | "messages" | "claims" | "checks" | "lifetimes" | "store", + boolean +>; + +interface StatePanelsProps { + kind: JwtKind; + step: JwtStep | null; + flashes: JwtFlashes; +} + +const flash = (on: boolean) => (on ? "viz-change-flash" : undefined); + +export const StatePanels = ({ kind, step, flashes }: StatePanelsProps) => { + const messagesPanel = ( + + + + ); + + if (kind === "refresh") { + return ( +
    + + + + {messagesPanel} + + + +
    + ); + } + + return ( +
    +
    + + + + {kind === "issue" ? ( + + + + ) : ( + + + + )} +
    + {messagesPanel} +
    + ); +}; diff --git a/src/components/visualizations/jwt-authentication/components/TokenParts.tsx b/src/components/visualizations/jwt-authentication/components/TokenParts.tsx new file mode 100644 index 0000000..0de66c1 --- /dev/null +++ b/src/components/visualizations/jwt-authentication/components/TokenParts.tsx @@ -0,0 +1,67 @@ +import { cn } from "@/lib/utils"; +import { + TOKEN_PART_BASE, + TOKEN_PART_LABELS, + TOKEN_PART_LABEL_COLOR, + TOKEN_PART_STATE, +} from "../helpers"; +import type { TokenPart } from "../types"; + +const partFingerprint = (part: TokenPart) => + `${part.id}|${part.state}|${part.encoded}|${part.decoded}`; + +const STATE_TAG: Partial> = { + active: "building", + match: "match", + miss: "mismatch", +}; + +export const TokenParts = ({ parts }: { parts: TokenPart[] }) => { + if (parts.length === 0) { + return ( +

    + no token yet +

    + ); + } + + return ( +
    + {parts.map((part, index) => ( +
    +
    + + {index > 0 && .} + {TOKEN_PART_LABELS[part.id]} + + {STATE_TAG[part.state] && ( + + {STATE_TAG[part.state]} + + )} +
    +
    +            {part.decoded}
    +          
    + {part.encoded && ( +

    + {part.encoded} +

    + )} +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/jwt-authentication/components/VerificationChecklist.tsx b/src/components/visualizations/jwt-authentication/components/VerificationChecklist.tsx new file mode 100644 index 0000000..db8b083 --- /dev/null +++ b/src/components/visualizations/jwt-authentication/components/VerificationChecklist.tsx @@ -0,0 +1,43 @@ +import { cn } from "@/lib/utils"; +import { CHECK_MARKS, CHECK_STYLES } from "../helpers"; +import type { VerificationCheck } from "../types"; + +export const VerificationChecklist = ({ + checks, +}: { + checks: VerificationCheck[]; +}) => { + if (checks.length === 0) { + return ( +

    + no request yet +

    + ); + } + + return ( +
      + {checks.map((check) => ( +
    1. + + {CHECK_MARKS[check.status]} + + + {check.label} + {check.detail && ( + + {check.detail} + + )} + +
    2. + ))} +
    + ); +}; diff --git a/src/components/visualizations/jwt-authentication/data.ts b/src/components/visualizations/jwt-authentication/data.ts index cb0ff5c..9b5ebf1 100644 --- a/src/components/visualizations/jwt-authentication/data.ts +++ b/src/components/visualizations/jwt-authentication/data.ts @@ -1 +1,749 @@ -export {}; +import type { FlowMessage } from "@/components/visualization-ui/MessageFlow"; +import type { MetricBar } from "@/components/visualization-ui/MetricBars"; +import type { + ClaimRow, + ClaimState, + JwtExample, + JwtStep, + StoreEntry, + TokenPart, + TokenPartState, + VerificationCheck, + CheckStatus, +} from "./types"; + +/* ------------------------------------------------------------------------ */ +/* Real values, computed with Node's crypto module and secret */ +/* "dev-secret-change-me". Header and payload are base64url with no padding. */ +/* ------------------------------------------------------------------------ */ + +const HEADER_JSON = '{"alg":"HS256","typ":"JWT"}'; +const HEADER_ENC = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; + +const PAYLOAD_JSON = + '{"sub":"42","role":"user",\n "iat":1700000000,"exp":1700003600}'; +const PAYLOAD_ENC = + "eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9"; +const SIG_ENC = "LIwEkPQKr4yu65AJFtEhpIQNhVvJ-sRp31Ksln_sC9U"; + +const TAMPERED_JSON = + '{"sub":"42","role":"admin",\n "iat":1700000000,"exp":1700003600}'; +const TAMPERED_ENC = + "eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAzNjAwfQ"; +const TAMPERED_RECOMPUTED = "I-1XaX3ABFaxFkQH42RpYfQ7BlpBGDFEuTc1MbFSCC0"; + +const EXPIRED_JSON = + '{"sub":"42","role":"user",\n "iat":1699990000,"exp":1699993600}'; +const EXPIRED_ENC = + "eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNjk5OTkwMDAwLCJleHAiOjE2OTk5OTM2MDB9"; +const EXPIRED_SIG = "VSHIMnM4CHdn4_d7BohrwMdE9x05xTBmsAtIs5FMaWU"; + +const SIG_FORMULA = 'HMAC-SHA256(\n header + "." + payload,\n secret)'; + +/* ------------------------------------------------------------------------ */ +/* Builders */ +/* ------------------------------------------------------------------------ */ + +const part = ( + id: TokenPart["id"], + decoded: string, + encoded: string, + state: TokenPartState, +): TokenPart => ({ id, decoded, encoded, state }); + +const header = (state: TokenPartState = "encoded", encoded = HEADER_ENC) => + part("header", HEADER_JSON, encoded, state); + +const payload = ( + state: TokenPartState = "encoded", + json = PAYLOAD_JSON, + encoded = PAYLOAD_ENC, +) => part("payload", json, encoded, state); + +const signature = ( + state: TokenPartState = "encoded", + encoded = SIG_ENC, + decoded = SIG_FORMULA, +) => part("signature", decoded, encoded, state); + +const msg = ( + id: string, + from: string, + to: string, + label: string, + status: FlowMessage["status"], + detail?: string, +): FlowMessage => ({ id, from, to, label, status, detail }); + +const claim = ( + name: string, + value: string, + meaning: string, + state: ClaimState = "neutral", +): ClaimRow => ({ claim: name, value, meaning, state }); + +const claims = (state: ClaimState): ClaimRow[] => [ + claim("sub", '"42"', "subject: the user id, a string per RFC 7519", state), + claim("role", '"user"', "custom claim the app reads for authorization", state), + claim("iat", "1700000000", "issued at, seconds since the Unix epoch", state), + claim("exp", "1700003600", "expires at, iat + 3600 (one hour)", state), +]; + +const check = ( + id: string, + label: string, + detail: string, + status: CheckStatus, +): VerificationCheck => ({ id, label, detail, status }); + +const checks = ( + parse: CheckStatus, + sig: CheckStatus, + exp: CheckStatus, + nbf: CheckStatus, + detail: Partial> = {}, +): VerificationCheck[] => [ + check("parse", "parse", detail.parse ?? 'split on "." into 3 parts', parse), + check( + "sig", + "signature", + detail.sig ?? "recompute HMAC, compare in constant time", + sig, + ), + check("exp", "exp", detail.exp ?? "exp must be greater than now", exp), + check("nbf", "nbf", detail.nbf ?? "not before, only if present", nbf), +]; + +const bar = ( + id: string, + label: string, + value: number, + display: string, + tone: MetricBar["tone"], + active = false, +): MetricBar => ({ id, label, value, display, tone, active }); + +const entry = ( + id: string, + tokenHash: string, + status: StoreEntry["status"], + active = false, +): StoreEntry => ({ + id, + tokenHash, + userId: "42", + expires: "2023-12-14", + status, + active, +}); + +const RT1 = "191de380120b3f..."; +const RT2 = "1c8a0f7101cfa9..."; + +const empty = { + tokenParts: [] as TokenPart[], + messages: [] as FlowMessage[], + claims: [] as ClaimRow[], + checks: [] as VerificationCheck[], + lifetimes: [] as MetricBar[], + store: [] as StoreEntry[], +}; + +const step = ( + fields: Partial & + Pick, +): JwtStep => ({ ...empty, ...fields }); + +/* ------------------------------------------------------------------------ */ +/* Example 1: login and token creation */ +/* ------------------------------------------------------------------------ */ + +const LOGIN_MSG = (status: FlowMessage["status"]) => + msg("login", "client", "server", "POST /login", status, "{ email, password }"); + +const ISSUE_CODE = [ + { num: 1, text: 'const { createHmac } = require("crypto");' }, + { num: 2, text: 'const b64url = (s) => Buffer.from(s).toString("base64url");' }, + { num: 3, text: "function sign(payload, secret) {" }, + { num: 4, text: ' const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));' }, + { num: 5, text: " const body = b64url(JSON.stringify(payload));" }, + { num: 6, text: ' const sig = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");' }, + { num: 7, text: " return `${header}.${body}.${sig}`;" }, + { num: 8, text: "}" }, + { num: 9, text: 'app.post("/login", async (req, res) => {' }, + { num: 10, text: " const user = await db.users.findByEmail(req.body.email);" }, + { num: 11, text: " if (!user || !(await bcrypt.compare(req.body.password, user.passwordHash))) {" }, + { num: 12, text: ' return res.status(401).json({ error: "invalid credentials" });' }, + { num: 13, text: " }" }, + { num: 14, text: " const now = Math.floor(Date.now() / 1000);" }, + { num: 15, text: " const token = sign({ sub: String(user.id), role: user.role, iat: now, exp: now + 3600 }, SECRET);" }, + { num: 16, text: " res.json({ token });" }, + { num: 17, text: "});" }, +]; + +const ISSUE_STEPS: JwtStep[] = [ + step({ + descriptionHtml: + 'The client sends POST /login with the email and password in the JSON body over HTTPS. This is the only request that carries the password, and the goal is to trade it for a token the client can present on later requests.', + activeLine: 9, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8], + messages: [LOGIN_MSG("active")], + activeActorId: "client", + }), + step({ + descriptionHtml: + "The server looks the user up by email. Nothing about the token exists yet, so a wrong email and a wrong password both end at line 12 with the same 401 body, which avoids leaking which accounts exist.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + }), + step({ + descriptionHtml: + "bcrypt.compare hashes the submitted password with the stored salt and compares it to user.passwordHash. The plain password is never stored and never goes into the token, because the payload of a JWT is readable by anyone who holds it.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + }), + step({ + descriptionHtml: + 'The password matched. The server reads its own clock in whole seconds and decides the claims: sub identifies the user, role is app data, iat is now and exp is one hour later. Numeric dates in a JWT are seconds, not milliseconds.', + activeLine: 14, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + claims: claims("active"), + }), + step({ + descriptionHtml: + 'sign starts with the header, which names the algorithm and token type. It is JSON encoded with base64url: the same alphabet as base64 except + becomes -, / becomes _, and the = padding is dropped so the result is safe in URLs and headers.', + activeLine: 4, + doneLines: [1, 2, 3, 8, 9, 10, 11, 13, 14], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + claims: claims("neutral"), + tokenParts: [header("active")], + }), + step({ + descriptionHtml: + 'The payload is encoded the same way. This is encoding, not encryption: anyone can run atob on this string and read every claim, so a JWT must never carry passwords, card numbers, or anything else you would not print in a log.', + activeLine: 5, + doneLines: [1, 2, 3, 4, 8, 9, 10, 11, 13, 14], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + claims: claims("active"), + tokenParts: [header("encoded"), payload("active")], + }), + step({ + descriptionHtml: + 'The signature is HMAC-SHA256 over the exact bytes header + "." + payload, keyed with a secret that only the server knows. Changing one character of either part changes the whole digest, and nobody without the secret can produce a digest that matches.', + activeLine: 6, + doneLines: [1, 2, 3, 4, 5, 8, 9, 10, 11, 13, 14], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + claims: claims("neutral"), + tokenParts: [header("encoded"), payload("encoded"), signature("active")], + }), + step({ + descriptionHtml: + "The three parts are joined with dots into the compact serialization. Signing proves integrity (nothing changed) and origin (someone with the secret made it), not secrecy. With HS256 one shared secret both signs and verifies; RS256 signs with a private key and lets any service verify with the public key.", + activeLine: 7, + doneLines: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 13, 14], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + claims: claims("neutral"), + tokenParts: [header("encoded"), payload("encoded"), signature("encoded")], + }), + step({ + descriptionHtml: + "Line 15 receives the token string. The server keeps no record of it: everything a later request needs to be authorized is inside the token and checkable with the secret, which is what makes the access token stateless.", + activeLine: 15, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14], + messages: [LOGIN_MSG("done")], + activeActorId: "server", + claims: claims("neutral"), + tokenParts: [header("encoded"), payload("encoded"), signature("encoded")], + }), + step({ + descriptionHtml: + 'The server responds 200 with the token. The client either keeps it in memory and sends it as Authorization: Bearer (safe from CSRF, lost on reload, exposed if XSS runs), or receives it in an httpOnly cookie (unreadable by scripts, but sent automatically so CSRF defenses are needed).', + activeLine: 16, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15], + messages: [ + LOGIN_MSG("done"), + msg("login-ok", "server", "client", "200 OK", "done", '{ token: "eyJhbGciOi..." }'), + ], + activeActorId: "client", + claims: claims("neutral"), + tokenParts: [header("encoded"), payload("encoded"), signature("encoded")], + }), +]; + +/* ------------------------------------------------------------------------ */ +/* Example 2: verifying a protected request */ +/* ------------------------------------------------------------------------ */ + +const VERIFY_CODE = [ + { num: 1, text: "function verify(token, secret) {" }, + { num: 2, text: ' const [header, body, sig] = token.split(".");' }, + { num: 3, text: ' const expected = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");' }, + { num: 4, text: " if (expected.length !== sig.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {" }, + { num: 5, text: ' throw new Error("invalid signature");' }, + { num: 6, text: " }" }, + { num: 7, text: ' const payload = JSON.parse(Buffer.from(body, "base64url").toString());' }, + { num: 8, text: ' if (payload.exp <= Math.floor(Date.now() / 1000)) throw new Error("token expired");' }, + { num: 9, text: " return payload;" }, + { num: 10, text: "}" }, + { num: 11, text: "function requireAuth(req, res, next) {" }, + { num: 12, text: ' const auth = req.headers.authorization ?? "";' }, + { num: 13, text: ' if (!auth.startsWith("Bearer ")) return res.status(401).json({ error: "missing token" });' }, + { num: 14, text: " try {" }, + { num: 15, text: " req.user = verify(auth.slice(7), SECRET);" }, + { num: 16, text: " next();" }, + { num: 17, text: " } catch (err) {" }, + { num: 18, text: " res.status(401).json({ error: err.message });" }, + { num: 19, text: " }" }, + { num: 20, text: "}" }, + { num: 21, text: 'app.get("/me", requireAuth, (req, res) => res.json({ id: req.user.sub, role: req.user.role }));' }, +]; + +const VERIFY_DONE_BASE = [1, 10, 11, 20, 21]; + +const ME_MSG = ( + id: string, + status: FlowMessage["status"], + detail = "Authorization: Bearer eyJhbGciOi...", +) => msg(id, "client", "server", "GET /me", status, detail); + +const VALID_MESSAGES = [ + ME_MSG("me-1", "done"), + msg("me-1-ok", "server", "client", "200 OK", "done", '{ id: "42", role: "user" }'), +]; + +const TAMPERED_MESSAGES = [ + ...VALID_MESSAGES, + ME_MSG("me-2", "done", "Bearer with role changed to admin"), + msg("me-2-401", "server", "client", "401 Unauthorized", "failed", '{ error: "invalid signature" }'), +]; + +const VERIFY_STEPS: JwtStep[] = [ + step({ + descriptionHtml: + 'The client calls GET /me with the token from login in the Authorization header. Express runs the requireAuth middleware before the route handler, and it starts by reading that header. Now is 1700001200, twenty minutes after the token was issued.', + activeLine: 12, + doneLines: VERIFY_DONE_BASE, + tokenParts: [header(), payload(), signature()], + messages: [ME_MSG("me-1", "active")], + activeActorId: "server", + checks: checks("pending", "pending", "pending", "pending"), + }), + step({ + descriptionHtml: + "The header must start with Bearer , the scheme from RFC 6750 that means \"whoever bears this token is authorized\". A missing or malformed header is rejected before any cryptography runs, which keeps the verifier cheap to call for unauthenticated traffic.", + activeLine: 13, + doneLines: [...VERIFY_DONE_BASE, 12], + tokenParts: [header(), payload(), signature()], + messages: [ME_MSG("me-1", "done")], + activeActorId: "server", + checks: checks("active", "pending", "pending", "pending"), + }), + step({ + descriptionHtml: + 'verify splits the token on . into the three base64url parts. The verifier never trusts the header to choose the algorithm: it always uses HS256 with its own secret, which is what closes the classic "alg":"none" and key confusion attacks.', + activeLine: 2, + doneLines: [...VERIFY_DONE_BASE, 12, 13, 14, 15], + tokenParts: [header("active"), payload("active"), signature()], + messages: [ME_MSG("me-1", "done")], + activeActorId: "server", + checks: checks("pass", "pending", "pending", "pending"), + }), + step({ + descriptionHtml: + 'The server recomputes HMAC-SHA256 over header.body exactly as the signer did. If the two encoded parts are byte for byte what the server signed, the digest will be identical; the signature is not decrypted, it is reproduced.', + activeLine: 3, + doneLines: [...VERIFY_DONE_BASE, 2, 12, 13, 14, 15], + tokenParts: [ + header(), + payload(), + signature("active", SIG_ENC, `recomputed:\n${SIG_ENC}`), + ], + messages: [ME_MSG("me-1", "done")], + activeActorId: "server", + checks: checks("pass", "active", "pending", "pending"), + }), + step({ + descriptionHtml: + 'The recomputed digest equals the presented one. timingSafeEqual compares every byte regardless of where the first difference is, so an attacker cannot measure response times to guess the signature one byte at a time. The length check comes first because timingSafeEqual throws on unequal lengths.', + activeLine: 4, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 12, 13, 14, 15], + tokenParts: [ + header(), + payload(), + signature("match", SIG_ENC, `recomputed:\n${SIG_ENC}`), + ], + messages: [ME_MSG("me-1", "done")], + activeActorId: "server", + checks: checks("pass", "pass", "pending", "pending", { + sig: "digest matches, constant-time compare", + }), + }), + step({ + descriptionHtml: + 'Only after the signature passes does the server decode and JSON.parse the payload. Decoding needs no secret, but trusting the decoded claims does: the signature check is what turns "a JSON blob someone sent" into "claims this server issued".', + activeLine: 7, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 4, 6, 12, 13, 14, 15], + tokenParts: [header(), payload("active"), signature("match")], + messages: [ME_MSG("me-1", "done")], + activeActorId: "server", + checks: checks("pass", "pass", "pending", "pending"), + }), + step({ + descriptionHtml: + 'exp is 1700003600 and now is 1700001200, so the token has forty minutes left. There is no nbf (not before) claim, so that check is skipped. Expiry is enforced by the verifier, not by the token: a token cannot delete itself.', + activeLine: 8, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 4, 6, 7, 12, 13, 14, 15], + tokenParts: [header(), payload("match"), signature("match")], + messages: [ME_MSG("me-1", "done")], + activeActorId: "server", + checks: checks("pass", "pass", "pass", "skipped", { + exp: "1700003600 > 1700001200, 40 min left", + nbf: "claim not present", + }), + }), + step({ + descriptionHtml: + 'verify returns the payload and the middleware attaches it as req.user, then calls next(). Downstream handlers read req.user.sub and req.user.role without touching the database, which is the whole performance argument for JWTs.', + activeLine: 16, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 4, 6, 7, 8, 9, 12, 13, 14, 15], + tokenParts: [header(), payload("match"), signature("match")], + messages: [ME_MSG("me-1", "done")], + activeActorId: "server", + checks: checks("pass", "pass", "pass", "skipped", { + exp: "1700003600 > 1700001200, 40 min left", + nbf: "claim not present", + }), + }), + step({ + descriptionHtml: + "The route handler responds 200 with data taken from the verified claims. Note that role came from the token, so authorization decisions are only as trustworthy as the signature check that preceded them.", + activeLine: 21, + doneLines: [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20], + tokenParts: [header(), payload("match"), signature("match")], + messages: VALID_MESSAGES, + activeActorId: "client", + checks: checks("pass", "pass", "pass", "skipped", { + exp: "1700003600 > 1700001200, 40 min left", + nbf: "claim not present", + }), + }), + step({ + descriptionHtml: + 'Second request. The client decoded the payload, changed "role":"user" to "role":"admin", re-encoded it, and kept the original signature. The parse step still passes: structurally this is a valid token.', + activeLine: 2, + doneLines: [...VERIFY_DONE_BASE, 12, 13, 14, 15], + tokenParts: [ + header(), + payload("active", TAMPERED_JSON, TAMPERED_ENC), + signature(), + ], + messages: [...VALID_MESSAGES, ME_MSG("me-2", "active", "Bearer with role changed to admin")], + activeActorId: "server", + checks: checks("pass", "pending", "pending", "pending"), + }), + step({ + descriptionHtml: + 'The recomputed HMAC over the edited payload is I-1XaX3A..., while the token still carries LIwEkPQK.... The attacker could not update the signature because they do not have the secret. timingSafeEqual returns false and line 5 throws.', + activeLine: 4, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 12, 13, 14, 15], + tokenParts: [ + header(), + payload("miss", TAMPERED_JSON, TAMPERED_ENC), + signature("miss", SIG_ENC, `recomputed:\n${TAMPERED_RECOMPUTED}`), + ], + messages: [...VALID_MESSAGES, ME_MSG("me-2", "active", "Bearer with role changed to admin")], + activeActorId: "server", + checks: checks("pass", "fail", "skipped", "skipped", { + sig: "I-1XaX3A... != LIwEkPQK...", + exp: "not reached", + nbf: "not reached", + }), + }), + step({ + descriptionHtml: + "The catch block answers 401 with the error message. The payload was never trusted, so the forged admin role never reached a handler. This is the property signing buys: the client can read the token, but cannot change it.", + activeLine: 18, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 4, 5, 12, 13, 14, 15, 17], + tokenParts: [ + header(), + payload("miss", TAMPERED_JSON, TAMPERED_ENC), + signature("miss", SIG_ENC, `recomputed:\n${TAMPERED_RECOMPUTED}`), + ], + messages: TAMPERED_MESSAGES, + activeActorId: "client", + checks: checks("pass", "fail", "skipped", "skipped", { + sig: "I-1XaX3A... != LIwEkPQK...", + exp: "not reached", + nbf: "not reached", + }), + }), + step({ + descriptionHtml: + "Third request, with a token issued about three hours ago (iat 1699990000, exp 1699993600). The signature was made with the real secret, so the HMAC matches and the signature check passes. A valid signature says who made the token, not whether it is still usable.", + activeLine: 4, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 12, 13, 14, 15], + tokenParts: [ + header(), + payload("encoded", EXPIRED_JSON, EXPIRED_ENC), + signature("match", EXPIRED_SIG, `recomputed:\n${EXPIRED_SIG}`), + ], + messages: [...TAMPERED_MESSAGES, ME_MSG("me-3", "active", "Bearer with a 3 hour old token")], + activeActorId: "server", + checks: checks("pass", "pass", "pending", "pending", { + sig: "digest matches, constant-time compare", + }), + }), + step({ + descriptionHtml: + 'exp 1699993600 is less than now 1700001200, so line 8 throws and the client gets 401 token expired. Because the server stores nothing per token, expiry is the only built-in way an access token stops working; that is why they are kept short and paired with a refresh token.', + activeLine: 18, + doneLines: [...VERIFY_DONE_BASE, 2, 3, 4, 6, 7, 8, 12, 13, 14, 15, 17], + tokenParts: [ + header(), + payload("miss", EXPIRED_JSON, EXPIRED_ENC), + signature("match", EXPIRED_SIG, `recomputed:\n${EXPIRED_SIG}`), + ], + messages: [ + ...TAMPERED_MESSAGES, + ME_MSG("me-3", "done", "Bearer with a 3 hour old token"), + msg("me-3-401", "server", "client", "401 Unauthorized", "failed", '{ error: "token expired" }'), + ], + activeActorId: "client", + checks: checks("pass", "pass", "fail", "skipped", { + sig: "digest matches, constant-time compare", + exp: "1699993600 <= 1700001200, expired 2h 6m ago", + nbf: "not reached", + }), + }), +]; + +/* ------------------------------------------------------------------------ */ +/* Example 3: refresh tokens and logout */ +/* ------------------------------------------------------------------------ */ + +const REFRESH_CODE = [ + { num: 1, text: 'app.post("/refresh", async (req, res) => {' }, + { num: 2, text: " const presented = req.cookies.refresh_token;" }, + { num: 3, text: " const row = await db.refreshTokens.findOne({ hash: sha256(presented) });" }, + { num: 4, text: " if (!row || row.revokedAt || row.expiresAt < Date.now()) {" }, + { num: 5, text: ' return res.status(401).json({ error: "refresh token invalid" });' }, + { num: 6, text: " }" }, + { num: 7, text: ' const next = randomBytes(32).toString("base64url");' }, + { num: 8, text: " await db.refreshTokens.rotate(row.id, sha256(next));" }, + { num: 9, text: ' res.cookie("refresh_token", next, { httpOnly: true, secure: true, sameSite: "strict", path: "/refresh" });' }, + { num: 10, text: " const now = Math.floor(Date.now() / 1000);" }, + { num: 11, text: " res.json({ token: sign({ sub: row.userId, role: row.role, iat: now, exp: now + 900 }, SECRET) });" }, + { num: 12, text: "});" }, + { num: 13, text: 'app.post("/logout", async (req, res) => {' }, + { num: 14, text: " await db.refreshTokens.revoke(sha256(req.cookies.refresh_token));" }, + { num: 15, text: ' res.clearCookie("refresh_token", { path: "/refresh" });' }, + { num: 16, text: " res.status(204).end();" }, + { num: 17, text: "});" }, +]; + +const ACCESS = (value: number, display: string, active = false) => + bar("access", "access", value, display, value === 0 ? "rose" : "cyan", active); +const REFRESH = (value: number, display: string, active = false) => + bar("refresh", "refresh", value, display, value === 0 ? "rose" : "violet", active); + +const LOGIN_DONE = msg( + "login-ok", + "server", + "client", + "200 login", + "done", + "access in body, refresh in Set-Cookie", +); +const ME_OK = msg("me-ok", "client", "server", "GET /me", "done", "200, no DB lookup"); +const ME_EXPIRED = msg("me-exp", "server", "client", "401 token expired", "failed", "access exp 1700000900 passed"); +const REFRESH_REQ = (status: FlowMessage["status"]) => + msg("refresh", "client", "server", "POST /refresh", status, "Cookie: refresh_token=..."); +const DB_FIND = (status: FlowMessage["status"]) => + msg("db-find", "server", "db", "findOne", status, "hash = sha256(presented)"); +const DB_ROW = msg("db-row", "db", "server", "row", "done", "user 42, not revoked"); +const DB_ROTATE = msg("db-rotate", "server", "db", "rotate", "done", "replace hash, keep row"); +const REFRESH_OK = msg("refresh-ok", "server", "client", "200 OK", "done", "new access + Set-Cookie"); +const ME_OK_2 = msg("me-ok-2", "client", "server", "GET /me", "done", "200 with the new token"); +const LOGOUT_REQ = (status: FlowMessage["status"]) => + msg("logout", "client", "server", "POST /logout", status, "Cookie: refresh_token=..."); +const DB_REVOKE = msg("db-revoke", "server", "db", "revoke", "done", "set revokedAt"); +const LOGOUT_OK = msg("logout-ok", "server", "client", "204 No Content", "done", "cookie cleared"); + +const REFRESH_STEPS: JwtStep[] = [ + step({ + descriptionHtml: + 'Login at 1700000000 issued two tokens. The access token is a JWT with exp 15 minutes out, returned in the JSON body. The refresh token is an opaque random string valid for 30 days, set as an httpOnly cookie, and only its SHA-256 hash is stored server side.', + activeLine: null, + doneLines: [], + lifetimes: [ACCESS(1, "15m 0s", true), REFRESH(1, "30d 0h", true)], + messages: [LOGIN_DONE], + activeActorId: "client", + store: [entry("rt1", RT1, "active")], + }), + step({ + descriptionHtml: + "Ten minutes later the client calls GET /me with the access token. The server verifies the signature and exp and answers without a database lookup. The refresh token is not sent here: its cookie is scoped to path=/refresh.", + activeLine: null, + doneLines: [], + lifetimes: [ACCESS(600 / 900, "5m 0s", true), REFRESH(0.9998, "29d 23h")], + messages: [LOGIN_DONE, ME_OK], + activeActorId: "server", + store: [entry("rt1", RT1, "active")], + }), + step({ + descriptionHtml: + 'At 1700000960 the access token is one minute past exp. The next GET /me fails with 401 token expired. The client does not ask the user to log in again; it uses the refresh token to get a new access token.', + activeLine: null, + doneLines: [], + lifetimes: [ACCESS(0, "expired", true), REFRESH(0.9996, "29d 23h")], + messages: [LOGIN_DONE, ME_OK, ME_EXPIRED], + activeActorId: "client", + store: [entry("rt1", RT1, "active")], + }), + step({ + descriptionHtml: + "The client calls POST /refresh. Because the cookie is httpOnly the JavaScript never saw the refresh token; the browser attaches it on its own because the path matches. The handler reads it from req.cookies.", + activeLine: 2, + doneLines: [1], + lifetimes: [ACCESS(0, "expired"), REFRESH(0.9996, "29d 23h", true)], + messages: [LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("active")], + activeActorId: "server", + store: [entry("rt1", RT1, "active")], + }), + step({ + descriptionHtml: + 'Unlike the access token, the refresh token is stateful: the server hashes the presented value and looks it up in an allow-list table. Storing the hash means a database leak does not hand out usable refresh tokens.', + activeLine: 3, + doneLines: [1, 2], + lifetimes: [ACCESS(0, "expired"), REFRESH(0.9996, "29d 23h", true)], + messages: [LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("active")], + activeActorId: "db", + store: [entry("rt1", RT1, "active", true)], + }), + step({ + descriptionHtml: + "The row exists, has no revokedAt, and its 30 day expiry has not passed. Any of those failing would end the session with 401 and force a real login, which is exactly the control a stateless access token cannot offer.", + activeLine: 4, + doneLines: [1, 2, 3], + lifetimes: [ACCESS(0, "expired"), REFRESH(0.9996, "29d 23h", true)], + messages: [LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("done"), DB_ROW], + activeActorId: "server", + store: [entry("rt1", RT1, "active", true)], + }), + step({ + descriptionHtml: + 'The server generates a fresh 32 byte random token and rotates the row so the old value stops working. Rotation limits how long a stolen refresh token stays useful, and if the old value is presented again later, the server can treat that reuse as theft and revoke the whole family.', + activeLine: 8, + doneLines: [1, 2, 3, 4, 6, 7], + lifetimes: [ACCESS(0, "expired"), REFRESH(1, "30d 0h", true)], + messages: [ + LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("done"), DB_ROW, DB_ROTATE, + ], + activeActorId: "db", + store: [entry("rt1", RT1, "rotated"), entry("rt2", RT2, "active", true)], + }), + step({ + descriptionHtml: + "The new refresh token goes back in a cookie with httpOnly (scripts cannot read it, so XSS cannot exfiltrate it), secure (HTTPS only), sameSite=strict (cross-site pages cannot trigger it, which blocks CSRF), and path=/refresh so it rides along only where it is needed.", + activeLine: 9, + doneLines: [1, 2, 3, 4, 6, 7, 8], + lifetimes: [ACCESS(0, "expired"), REFRESH(1, "30d 0h", true)], + messages: [ + LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("done"), DB_ROW, DB_ROTATE, + ], + activeActorId: "server", + store: [entry("rt1", RT1, "rotated"), entry("rt2", RT2, "active")], + }), + step({ + descriptionHtml: + 'A new access token is signed with iat 1700000960 and exp 1700001860, another 15 minutes. The user identity came from the refresh row, not from the expired JWT, so nothing from the old token was trusted. The response carries the JWT in the body and the cookie in Set-Cookie.', + activeLine: 11, + doneLines: [1, 2, 3, 4, 6, 7, 8, 9, 10], + lifetimes: [ACCESS(1, "15m 0s", true), REFRESH(1, "30d 0h")], + messages: [ + LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("done"), DB_ROW, DB_ROTATE, REFRESH_OK, + ], + activeActorId: "client", + store: [entry("rt1", RT1, "rotated"), entry("rt2", RT2, "active")], + }), + step({ + descriptionHtml: + "The client retries GET /me with the new access token and gets 200. From the user's point of view nothing happened. This loop, short access token plus long stateful refresh token, is the standard shape of JWT sessions.", + activeLine: null, + doneLines: [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12], + lifetimes: [ACCESS(890 / 900, "14m 50s", true), REFRESH(1, "30d 0h")], + messages: [ + LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("done"), DB_ROW, DB_ROTATE, REFRESH_OK, ME_OK_2, + ], + activeActorId: "server", + store: [entry("rt1", RT1, "rotated"), entry("rt2", RT2, "active")], + }), + step({ + descriptionHtml: + 'The user logs out. POST /logout marks the refresh row as revoked, so no further access tokens can be minted from it. Revocation is possible here precisely because the refresh token is looked up in the database on every use.', + activeLine: 14, + doneLines: [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13], + lifetimes: [ACCESS(860 / 900, "14m 20s"), REFRESH(0, "revoked", true)], + messages: [ + LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("done"), DB_ROW, DB_ROTATE, REFRESH_OK, ME_OK_2, + LOGOUT_REQ("done"), DB_REVOKE, + ], + activeActorId: "db", + store: [entry("rt1", RT1, "rotated"), entry("rt2", RT2, "revoked", true)], + }), + step({ + descriptionHtml: + "The cookie is cleared and the server answers 204. The access token, however, still verifies for another 14 minutes: the server has no record of it and no way to invalidate a signature it already made. That gap is the cost of stateless tokens, which is why exp is kept short and high-risk actions re-check the database or use a denylist keyed by jti.", + activeLine: 16, + doneLines: [1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + lifetimes: [ACCESS(850 / 900, "14m 10s", true), REFRESH(0, "revoked")], + messages: [ + LOGIN_DONE, ME_OK, ME_EXPIRED, REFRESH_REQ("done"), DB_FIND("done"), DB_ROW, DB_ROTATE, REFRESH_OK, ME_OK_2, + LOGOUT_REQ("done"), DB_REVOKE, LOGOUT_OK, + ], + activeActorId: "client", + store: [entry("rt1", RT1, "rotated"), entry("rt2", RT2, "revoked")], + }), +]; + +/* ------------------------------------------------------------------------ */ + +export const EXAMPLES: JwtExample[] = [ + { + id: "issue", + title: "Login and token creation", + description: + "POST /login checks the password hash, builds the header and payload, signs them with HMAC-SHA256, and returns the three dot-joined parts.", + kind: "issue", + codeLines: ISSUE_CODE, + steps: ISSUE_STEPS, + }, + { + id: "verify", + title: "Verifying a protected request", + description: + "Middleware recomputes the signature over header.payload, compares it in constant time, checks exp, then rejects a tampered token and an expired one.", + kind: "verify", + codeLines: VERIFY_CODE, + steps: VERIFY_STEPS, + }, + { + id: "refresh", + title: "Refresh tokens and logout", + description: + "A 15 minute access token expires, the httpOnly refresh cookie mints a new one after a database check and rotation, and logout revokes the refresh token.", + kind: "refresh", + codeLines: REFRESH_CODE, + steps: REFRESH_STEPS, + }, +]; diff --git a/src/components/visualizations/jwt-authentication/helpers.ts b/src/components/visualizations/jwt-authentication/helpers.ts index cb0ff5c..0bde6db 100644 --- a/src/components/visualizations/jwt-authentication/helpers.ts +++ b/src/components/visualizations/jwt-authentication/helpers.ts @@ -1 +1,83 @@ -export {}; +import { + createKindBadgeClass, + createKindLabel, +} from "@/lib/visualization-helpers"; +import type { + CheckStatus, + ClaimState, + JwtKind, + StoreEntryStatus, + TokenPartId, + TokenPartState, +} from "./types"; + +export const kindBadgeClass = createKindBadgeClass({ + issue: "bg-amber-500/15 text-amber-400 border-amber-500/25", + verify: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25", + refresh: "bg-violet-500/15 text-violet-400 border-violet-500/25", +}); + +export const kindLabel = createKindLabel({ + issue: "issue token", + verify: "verify token", + refresh: "refresh and logout", +}); + +export const TOKEN_PART_LABELS: Record = { + header: "header", + payload: "payload", + signature: "signature", +}; + +/** Base colour per part: amber header, cyan payload, violet signature. */ +export const TOKEN_PART_BASE: Record = { + header: "border-amber-400/30 bg-amber-400/8 text-amber-100", + payload: "border-cyan-400/30 bg-cyan-400/8 text-cyan-100", + signature: "border-violet-400/30 bg-violet-400/8 text-violet-100", +}; + +export const TOKEN_PART_LABEL_COLOR: Record = { + header: "text-amber-300", + payload: "text-cyan-300", + signature: "text-violet-300", +}; + +/** State overrides layered on top of the base colour. */ +export const TOKEN_PART_STATE: Record = { + idle: "opacity-50", + active: "ring-1 ring-pink-300/60 shadow-[0_0_18px_rgba(244,114,182,0.18)]", + encoded: "", + match: + "border-emerald-300/50 bg-emerald-400/10 text-emerald-100 shadow-[0_0_16px_rgba(52,211,153,0.16)]", + miss: "border-rose-400/50 bg-rose-400/10 text-rose-100 shadow-[0_0_16px_rgba(251,113,133,0.16)]", +}; + +export const CLAIM_ROW_STYLES: Record = { + neutral: "text-slate-300", + active: "bg-cyan-400/8 text-cyan-100", + ok: "bg-emerald-400/8 text-emerald-100", + fail: "bg-rose-400/8 text-rose-100", +}; + +export const CHECK_STYLES: Record = { + pending: "border-slate-700/50 bg-slate-900/40 text-slate-500", + active: + "border-cyan-300/50 bg-cyan-400/12 text-cyan-100 shadow-[0_0_16px_rgba(34,211,238,0.16)]", + pass: "border-emerald-300/30 bg-emerald-400/8 text-emerald-100", + fail: "border-rose-400/40 bg-rose-400/10 text-rose-200", + skipped: "border-slate-700/40 bg-slate-900/40 text-slate-600 line-through", +}; + +export const CHECK_MARKS: Record = { + pending: "..", + active: ">>", + pass: "ok", + fail: "no", + skipped: "--", +}; + +export const STORE_STYLES: Record = { + active: "border-emerald-300/30 bg-emerald-400/8 text-emerald-100", + rotated: "border-slate-700/40 bg-slate-900/40 text-slate-500 line-through", + revoked: "border-rose-400/40 bg-rose-400/10 text-rose-200 line-through", +}; diff --git a/src/components/visualizations/jwt-authentication/index.tsx b/src/components/visualizations/jwt-authentication/index.tsx index b4d58e1..f3ed7da 100644 --- a/src/components/visualizations/jwt-authentication/index.tsx +++ b/src/components/visualizations/jwt-authentication/index.tsx @@ -1,14 +1,167 @@ "use client"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { + CodeBlock, + type CodeBlockLine, +} from "@/components/visualization-ui/CodeBlock"; +import { TransportControls } from "@/components/visualization-ui/TransportControls"; import { ExampleSelector } from "@/components/visualization-ui/ExampleSelector"; +import { ToolbarPortal } from "@/components/layout/ToolbarPortal"; +import { cn } from "@/lib/utils"; +import { + VISUALIZATION_PANEL_TITLES, + VISUALIZATION_EMPTY_STATES, +} from "@/lib/visualization/uiCopy"; +import { useStepPlayback } from "@/hooks/useStepPlayback"; +import { useChangeFlash } from "@/hooks/useChangeFlash"; +import { EXAMPLES } from "./data"; +import { kindBadgeClass, kindLabel } from "./helpers"; +import { StatePanels } from "./components/StatePanels"; -// STUB: replaced by the real implementation. Keeps the build green while -// topics are authored in parallel. export const JwtAuthentication = () => { - void ExampleSelector; + const [activeExampleId, setActiveExampleId] = useState(EXAMPLES[0].id); + + const handleExampleChange = (id: string) => { + setActiveExampleId(id); + }; + + const example = + EXAMPLES.find((e) => e.id === activeExampleId) ?? EXAMPLES[0]; + + const { + currentStepIndex, + isPlaying, + speedLevel, + speedLabel, + canStep, + canStepBack, + togglePlay, + step: handleStep, + stepBack: handleStepBack, + reset: handleReset, + setSpeedLevel, + jumpTo, + } = useStepPlayback({ + totalSteps: example.steps.length, + initialStep: -1, + resetKey: activeExampleId, + }); + + const currentStep = + currentStepIndex >= 0 ? example.steps[currentStepIndex] : null; + + const flashes = useChangeFlash( + { + description: currentStep?.descriptionHtml, + tokenParts: currentStep?.tokenParts, + messages: currentStep?.messages, + claims: currentStep?.claims, + checks: currentStep?.checks, + lifetimes: currentStep?.lifetimes, + store: currentStep?.store, + }, + currentStepIndex, + ); + return ( -

    - JWT Authentication visualization coming soon. -

    + <> + +
    +
    +
    + ( + + {kindLabel(ex.kind)} + + )} + /> + + {kindLabel(example.kind)} + +
    + + +
    + +
    + {currentStep?.descriptionHtml ? ( +

    + ) : ( +

    + {VISUALIZATION_EMPTY_STATES.stepDescription} +

    + )} +
    +
    +
    + +
    +
    + + { + const isActive = currentStep?.activeLine === line.num; + const isDone = + currentStep?.doneLines.includes(line.num) ?? false; + return { + key: line.num, + lineNumber: line.num, + text: line.text, + className: cn( + isActive && "is-active", + isDone && !isActive && "is-done", + ), + }; + })} + /> + + + +
    +
    + ); }; diff --git a/src/components/visualizations/jwt-authentication/types.ts b/src/components/visualizations/jwt-authentication/types.ts index cb0ff5c..a8e97c8 100644 --- a/src/components/visualizations/jwt-authentication/types.ts +++ b/src/components/visualizations/jwt-authentication/types.ts @@ -1 +1,67 @@ -export {}; +import type { SourceLine } from "@/types/visualization"; +import type { ExampleOption } from "@/components/visualization-ui/ExampleSelector"; +import type { FlowMessage } from "@/components/visualization-ui/MessageFlow"; +import type { MetricBar } from "@/components/visualization-ui/MetricBars"; + +export type JwtKind = "issue" | "verify" | "refresh"; + +export type TokenPartId = "header" | "payload" | "signature"; + +export type TokenPartState = "idle" | "active" | "encoded" | "match" | "miss"; + +export interface TokenPart { + id: TokenPartId; + /** Human readable form: JSON for header and payload, the formula for the signature. */ + decoded: string; + /** base64url form, empty until the part has been encoded. */ + encoded: string; + state: TokenPartState; +} + +export type ClaimState = "neutral" | "active" | "ok" | "fail"; + +export interface ClaimRow { + claim: string; + value: string; + meaning: string; + state: ClaimState; +} + +export type CheckStatus = "pending" | "active" | "pass" | "fail" | "skipped"; + +export interface VerificationCheck { + id: string; + label: string; + detail: string; + status: CheckStatus; +} + +export type StoreEntryStatus = "active" | "rotated" | "revoked"; + +export interface StoreEntry { + id: string; + tokenHash: string; + userId: string; + expires: string; + status: StoreEntryStatus; + active?: boolean; +} + +export interface JwtStep { + descriptionHtml: string; + activeLine: number | null; + doneLines: number[]; + tokenParts: TokenPart[]; + messages: FlowMessage[]; + activeActorId?: string; + claims: ClaimRow[]; + checks: VerificationCheck[]; + lifetimes: MetricBar[]; + store: StoreEntry[]; +} + +export interface JwtExample extends ExampleOption { + kind: JwtKind; + codeLines: SourceLine[]; + steps: JwtStep[]; +} diff --git a/src/components/visualizations/state-batching/components/EventLoopPanel.tsx b/src/components/visualizations/state-batching/components/EventLoopPanel.tsx new file mode 100644 index 0000000..4af8ea2 --- /dev/null +++ b/src/components/visualizations/state-batching/components/EventLoopPanel.tsx @@ -0,0 +1,69 @@ +import { cn } from "@/lib/utils"; +import type { EventLoopSnapshot } from "../types"; +import { pendingStyle, runKindLabel, runKindStyle } from "../helpers"; + +export const EventLoopPanel = ({ + snapshot, +}: { + snapshot: EventLoopSnapshot | null; +}) => { + if (!snapshot) { + return ( +

    + waiting +

    + ); + } + + const isIdle = snapshot.runningKind === "idle"; + + return ( +
    +
    +
    + running now +
    +
    + + {runKindLabel(snapshot.runningKind)} + + + {snapshot.running ?? "call stack empty"} + +
    +
    + +
    +
    + waiting to run +
    + {snapshot.pending.length === 0 ? ( +

    nothing queued

    + ) : ( +
    + {snapshot.pending.map((work) => ( +
    + + {work.kind} + + {work.label} +
    + ))} +
    + )} +
    +
    + ); +}; diff --git a/src/components/visualizations/state-batching/components/HookStatePanel.tsx b/src/components/visualizations/state-batching/components/HookStatePanel.tsx new file mode 100644 index 0000000..54d9976 --- /dev/null +++ b/src/components/visualizations/state-batching/components/HookStatePanel.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/lib/utils"; +import type { HookSnapshot } from "../types"; + +export const HookStatePanel = ({ hooks }: { hooks: HookSnapshot[] }) => { + if (hooks.length === 0) { + return ( +

    + waiting +

    + ); + } + + return ( +
    + {hooks.map((hook, index) => { + const hasPending = hook.pending !== null; + return ( +
    +
    + + Hook #{index} + + + useState + + {hook.name} +
    +
    + memoizedState + -> + {hook.current} +
    +
    + pending + -> + {hasPending ? ( + {hook.pending} + ) : ( + none + )} +
    +
    + ); + })} +
    + ); +}; diff --git a/src/components/visualizations/state-batching/components/RenderLogPanel.tsx b/src/components/visualizations/state-batching/components/RenderLogPanel.tsx new file mode 100644 index 0000000..0bb3738 --- /dev/null +++ b/src/components/visualizations/state-batching/components/RenderLogPanel.tsx @@ -0,0 +1,60 @@ +import { cn } from "@/lib/utils"; +import type { RenderLogEntry, RenderTotals } from "../types"; +import { renderLogStyle, renderLogTag } from "../helpers"; + +interface RenderLogPanelProps { + entries: RenderLogEntry[]; + totals?: RenderTotals; +} + +export const RenderLogPanel = ({ entries, totals }: RenderLogPanelProps) => { + if (entries.length === 0) { + return ( +

    + no renders yet +

    + ); + } + + return ( +
    + {totals ? ( +
    +
    + + React 17 + +
    + {totals.react17} {totals.react17 === 1 ? "render" : "renders"} +
    +
    +
    + + React 18+ + +
    + {totals.react18} {totals.react18 === 1 ? "render" : "renders"} +
    +
    +
    + ) : null} +
    + {entries.map((entry) => ( +
    + + {renderLogTag(entry.kind)} + + {entry.label} + {entry.detail} +
    + ))} +
    +
    + ); +}; diff --git a/src/components/visualizations/state-batching/components/UpdateQueuePanel.tsx b/src/components/visualizations/state-batching/components/UpdateQueuePanel.tsx new file mode 100644 index 0000000..a8ff67a --- /dev/null +++ b/src/components/visualizations/state-batching/components/UpdateQueuePanel.tsx @@ -0,0 +1,48 @@ +import { cn } from "@/lib/utils"; +import type { QueuedUpdate } from "../types"; +import { updateStatusStyle } from "../helpers"; + +const fingerprint = (u: QueuedUpdate) => + `${u.id}|${u.status}|${u.result ?? ""}`; + +export const UpdateQueuePanel = ({ updates }: { updates: QueuedUpdate[] }) => { + if (updates.length === 0) { + return ( +

    + queue empty +

    + ); + } + + return ( +
    + {updates.map((update, index) => ( +
    + + #{index + 1} + + + {update.payloadKind} + + {update.hook} + {update.payload} + {update.result ? ( + + {update.result} + + ) : ( + + {update.status} + + )} +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/state-batching/data.ts b/src/components/visualizations/state-batching/data.ts index cb0ff5c..28bf6a9 100644 --- a/src/components/visualizations/state-batching/data.ts +++ b/src/components/visualizations/state-batching/data.ts @@ -1 +1,758 @@ -export {}; +import type { + EventLoopSnapshot, + HookSnapshot, + QueuedUpdate, + RenderLogEntry, + StateBatchingExample, +} from "./types"; + +/* ── small builders so each step stays a readable full snapshot ── */ + +const idle: EventLoopSnapshot = { + running: null, + runningKind: "idle", + pending: [], +}; + +const clickTask = (pending: EventLoopSnapshot["pending"] = []): EventLoopSnapshot => ({ + running: "click event: handleClick()", + runningKind: "task", + pending, +}); + +const reactFlush = ( + label: string, + pending: EventLoopSnapshot["pending"] = [], +): EventLoopSnapshot => ({ + running: label, + runningKind: "react", + pending, +}); + +const hook = ( + name: string, + current: string, + pending: string | null = null, +): HookSnapshot => ({ name, current, pending }); + +const value = ( + id: string, + hookName: string, + call: string, + payload: string, + status: QueuedUpdate["status"] = "queued", + result?: string, +): QueuedUpdate => ({ + id, + hook: hookName, + call, + payload, + payloadKind: "value", + status, + result, +}); + +const updater = ( + id: string, + hookName: string, + call: string, + payload: string, + status: QueuedUpdate["status"] = "queued", + result?: string, +): QueuedUpdate => ({ + id, + hook: hookName, + call, + payload, + payloadKind: "updater", + status, + result, +}); + +const render = (id: string, label: string, detail: string): RenderLogEntry => ({ + id, + kind: "render", + label, + detail, +}); + +const commit = (id: string, label: string, detail: string): RenderLogEntry => ({ + id, + kind: "commit", + label, + detail, +}); + +const legacy = (id: string, label: string, detail: string): RenderLogEntry => ({ + id, + kind: "legacy", + label, + detail, +}); + +const note = (id: string, label: string, detail: string): RenderLogEntry => ({ + id, + kind: "note", + label, + detail, +}); + +const MOUNT_LOG: RenderLogEntry[] = [ + render("r1", "Render #1", "mount, count = 0"), + commit("c1", "Commit #1", "DOM shows 0"), +]; + +/* ── Example 1: three setState calls, one render ── */ + +const BATCH_QUEUE_QUEUED = (n: number): QueuedUpdate[] => + [1, 2, 3].slice(0, n).map((i) => + value(`b${i}`, "count", "setCount(count + 1)", "1"), + ); + +const BATCH_QUEUE_APPLIED: QueuedUpdate[] = [1, 2, 3].map((i) => + value(`b${i}`, "count", "setCount(count + 1)", "1", "applied", "-> 1"), +); + +const batchExample: StateBatchingExample = { + id: "batch", + title: "Three setState calls, one render", + description: + "Three setCount(count + 1) calls in one click handler queue three updates but produce one render, and count ends at 1, not 3.", + kind: "batch", + codeLines: [ + { num: 1, text: "function Counter() {" }, + { num: 2, text: " const [count, setCount] = useState(0);" }, + { num: 3, text: " function handleClick() {" }, + { num: 4, text: " setCount(count + 1);" }, + { num: 5, text: " setCount(count + 1);" }, + { num: 6, text: " setCount(count + 1);" }, + { num: 7, text: " console.log(count);" }, + { num: 8, text: " }" }, + { num: 9, text: " return ;" }, + { num: 10, text: "}" }, + ], + steps: [ + { + descriptionHtml: + "Mount render. useState(0) creates a hook object on the fiber with memoizedState = 0 and an empty update queue. The render returns a button showing 0 and React commits it.", + activeLine: 2, + doneLines: [1], + updateQueue: [], + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: reactFlush("React render: Counter() mount"), + consoleOutput: [], + }, + { + descriptionHtml: + "The committed render defined handleClick while count was 0. That function closes over the constant 0; nothing inside it can observe a newer value until React renders again and creates a new closure.", + activeLine: 9, + doneLines: [1, 2, 3, 8], + updateQueue: [], + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: idle, + consoleOutput: [], + }, + { + descriptionHtml: + "The user clicks. The browser runs the click event as a task, React's root listener dispatches it, and handleClick runs inside React's batched event context. Any state updates issued before the handler returns are held, not rendered.", + activeLine: 3, + doneLines: [1, 2, 9], + updateQueue: [], + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: clickTask(), + consoleOutput: [], + }, + { + descriptionHtml: + "setCount(count + 1) evaluates its argument first. count is the closure's 0, so the call stores a value update with payload 1 on the hook's queue. The hook's memoizedState is untouched.", + activeLine: 4, + doneLines: [1, 2, 3, 9], + updateQueue: BATCH_QUEUE_QUEUED(1), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: clickTask(), + consoleOutput: [], + }, + { + descriptionHtml: + "The second call computes count + 1 again. No render has happened, so count is still the snapshot value 0, and a second update with payload 1 is appended. State is a snapshot per render, not a live variable.", + activeLine: 5, + doneLines: [1, 2, 3, 4, 9], + updateQueue: BATCH_QUEUE_QUEUED(2), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: clickTask(), + consoleOutput: [], + }, + { + descriptionHtml: + "Third call, same story: payload 1. The queue now holds three updates that all say \"replace with 1\". Each setCount also schedules work on the root, but React sees it is inside a batched event and does not start rendering.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5, 9], + updateQueue: BATCH_QUEUE_QUEUED(3), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: clickTask(), + consoleOutput: [], + }, + { + descriptionHtml: + "console.log(count) prints 0. The three queued updates have not been applied, and even if they had, this closure would still hold the value from the render that created it.", + activeLine: 7, + doneLines: [1, 2, 3, 4, 5, 6, 9], + updateQueue: BATCH_QUEUE_QUEUED(3), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: clickTask(), + consoleOutput: ["0"], + }, + { + descriptionHtml: + "The handler returns and the batched event context ends. React now flushes the sync work it scheduled: one render pass for Counter, regardless of how many setState calls happened in between.", + activeLine: 8, + doneLines: [1, 2, 3, 4, 5, 6, 7, 9], + updateQueue: BATCH_QUEUE_QUEUED(3), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: reactFlush("React flush: end of batched click event"), + consoleOutput: ["0"], + }, + { + descriptionHtml: + "React calls Counter() again. When useState runs, it walks the queue from the base state 0: replace with 1, replace with 1, replace with 1. The result is 1, and the render returns a button showing 1.", + activeLine: 2, + doneLines: [1, 3, 4, 5, 6, 7, 8, 9], + updateQueue: BATCH_QUEUE_APPLIED, + hooks: [hook("count", "0", "1")], + renderLog: [ + ...MOUNT_LOG, + render("r2", "Render #2", "queue 0 -> 1 -> 1 -> 1, count = 1"), + ], + eventLoop: reactFlush("React render: Counter() with queued updates"), + consoleOutput: ["0"], + }, + { + descriptionHtml: + "Commit. The DOM text node changes from 0 to 1, memoizedState becomes 1, and the queue is cleared. Three calls, one render, one commit. To get 3 you need updater functions, shown in the next example.", + activeLine: 9, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8], + updateQueue: [], + hooks: [hook("count", "1")], + renderLog: [ + ...MOUNT_LOG, + render("r2", "Render #2", "queue 0 -> 1 -> 1 -> 1, count = 1"), + commit("c2", "Commit #2", "DOM shows 1"), + ], + eventLoop: idle, + consoleOutput: ["0"], + }, + ], +}; + +/* ── Example 2: updater functions ── */ + +const TRIPLE_QUEUED = (n: number): QueuedUpdate[] => + [1, 2, 3].slice(0, n).map((i) => + updater(`t${i}`, "count", "setCount(c => c + 1)", "c => c + 1"), + ); + +const TRIPLE_APPLIED: QueuedUpdate[] = [ + updater("t1", "count", "setCount(c => c + 1)", "c => c + 1", "applied", "0 -> 1"), + updater("t2", "count", "setCount(c => c + 1)", "c => c + 1", "applied", "1 -> 2"), + updater("t3", "count", "setCount(c => c + 1)", "c => c + 1", "applied", "2 -> 3"), +]; + +const MIXED_QUEUED: QueuedUpdate[] = [ + value("m1", "count", "setCount(count + 5)", "8"), + updater("m2", "count", "setCount(c => c + 1)", "c => c + 1"), + value("m3", "count", "setCount(42)", "42"), +]; + +const MIXED_APPLIED: QueuedUpdate[] = [ + value("m1", "count", "setCount(count + 5)", "8", "applied", "3 -> 8"), + updater("m2", "count", "setCount(c => c + 1)", "c => c + 1", "applied", "8 -> 9"), + value("m3", "count", "setCount(42)", "42", "applied", "9 -> 42"), +]; + +const UPDATER_LOG_AFTER_TRIPLE: RenderLogEntry[] = [ + ...MOUNT_LOG, + render("r2", "Render #2", "queue 0 -> 1 -> 2 -> 3, count = 3"), + commit("c2", "Commit #2", "DOM shows 3"), +]; + +const updaterExample: StateBatchingExample = { + id: "updater", + title: "Updater functions", + description: + "setCount(c => c + 1) queues a function instead of a value. React runs each one against the result of the previous update, so three calls give 3, and mixing values with updaters shows the difference.", + kind: "updater", + codeLines: [ + { num: 1, text: "function Counter() {" }, + { num: 2, text: " const [count, setCount] = useState(0);" }, + { num: 3, text: " function handleTriple() {" }, + { num: 4, text: " setCount(c => c + 1);" }, + { num: 5, text: " setCount(c => c + 1);" }, + { num: 6, text: " setCount(c => c + 1);" }, + { num: 7, text: " }" }, + { num: 8, text: " function handleMixed() {" }, + { num: 9, text: " setCount(count + 5);" }, + { num: 10, text: " setCount(c => c + 1);" }, + { num: 11, text: " setCount(42);" }, + { num: 12, text: " }" }, + { num: 13, text: " return (" }, + { num: 14, text: " <>" }, + { num: 15, text: " " }, + { num: 16, text: " " }, + { num: 17, text: " {count}" }, + { num: 18, text: " " }, + { num: 19, text: " );" }, + { num: 20, text: "}" }, + ], + steps: [ + { + descriptionHtml: + "Mount render with count = 0. Both handlers are created during this render and close over count === 0. React commits the two buttons and the span.", + activeLine: 2, + doneLines: [1], + updateQueue: [], + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: reactFlush("React render: Counter() mount"), + consoleOutput: [], + }, + { + descriptionHtml: + "The user clicks +3. handleTriple runs inside the batched click task, so updates queue up and rendering waits until the handler returns.", + activeLine: 3, + doneLines: [1, 2, 13, 14, 15, 16, 17, 18, 19], + updateQueue: [], + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: { ...clickTask(), running: "click event: handleTriple()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "setCount(c => c + 1) does not call the function. It stores the function itself on the queue as an updater. Nothing is computed yet, so it does not matter what count is in this closure.", + activeLine: 4, + doneLines: [1, 2, 3, 13, 14, 15, 16, 17, 18, 19], + updateQueue: TRIPLE_QUEUED(1), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: { ...clickTask(), running: "click event: handleTriple()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "A second updater is appended. The queue is a linked list on the hook object; each entry records whether it is a plain value or a function to call later.", + activeLine: 5, + doneLines: [1, 2, 3, 4, 13, 14, 15, 16, 17, 18, 19], + updateQueue: TRIPLE_QUEUED(2), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: { ...clickTask(), running: "click event: handleTriple()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "Third updater queued. Three functions are waiting, and memoizedState is still 0. Nothing has rendered because the click handler has not returned.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19], + updateQueue: TRIPLE_QUEUED(3), + hooks: [hook("count", "0")], + renderLog: MOUNT_LOG, + eventLoop: { ...clickTask(), running: "click event: handleTriple()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "The handler returns and React renders once. useState processes the queue in order, feeding each updater the result of the previous one: 0 -> 1, 1 -> 2, 2 -> 3. The render sees count = 3.", + activeLine: 2, + doneLines: [1, 3, 4, 5, 6, 7, 13, 14, 15, 16, 17, 18, 19], + updateQueue: TRIPLE_APPLIED, + hooks: [hook("count", "0", "3")], + renderLog: [ + ...MOUNT_LOG, + render("r2", "Render #2", "queue 0 -> 1 -> 2 -> 3, count = 3"), + ], + eventLoop: reactFlush("React render: Counter() with 3 updaters"), + consoleOutput: [], + }, + { + descriptionHtml: + "Commit. The span shows 3 and the hook stores 3. This render also created a fresh handleMixed whose closure now holds count === 3.", + activeLine: 17, + doneLines: [1, 2, 3, 4, 5, 6, 7, 13, 14, 15, 16, 18, 19], + updateQueue: [], + hooks: [hook("count", "3")], + renderLog: UPDATER_LOG_AFTER_TRIPLE, + eventLoop: idle, + consoleOutput: [], + }, + { + descriptionHtml: + "The user clicks mix. handleMixed runs inside a new batched click task. Its count is the snapshot from Render #2, which is 3.", + activeLine: 8, + doneLines: [1, 2, 13, 14, 15, 16, 17, 18, 19], + updateQueue: [], + hooks: [hook("count", "3")], + renderLog: UPDATER_LOG_AFTER_TRIPLE, + eventLoop: { ...clickTask(), running: "click event: handleMixed()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "setCount(count + 5) evaluates to 3 + 5 and queues the value 8. A value update ignores whatever is pending ahead of it; it simply replaces the state when its turn comes.", + activeLine: 9, + doneLines: [1, 2, 8, 13, 14, 15, 16, 17, 18, 19], + updateQueue: MIXED_QUEUED.slice(0, 1), + hooks: [hook("count", "3")], + renderLog: UPDATER_LOG_AFTER_TRIPLE, + eventLoop: { ...clickTask(), running: "click event: handleMixed()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "setCount(c => c + 1) queues an updater behind the value. When it runs, c will be the pending state at that point in the queue, which is 8, not the closure's 3.", + activeLine: 10, + doneLines: [1, 2, 8, 9, 13, 14, 15, 16, 17, 18, 19], + updateQueue: MIXED_QUEUED.slice(0, 2), + hooks: [hook("count", "3")], + renderLog: UPDATER_LOG_AFTER_TRIPLE, + eventLoop: { ...clickTask(), running: "click event: handleMixed()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "setCount(42) queues a third entry, a plain value. Whatever the earlier updates compute, this one overwrites it, because queue order is call order.", + activeLine: 11, + doneLines: [1, 2, 8, 9, 10, 13, 14, 15, 16, 17, 18, 19], + updateQueue: MIXED_QUEUED, + hooks: [hook("count", "3")], + renderLog: UPDATER_LOG_AFTER_TRIPLE, + eventLoop: { ...clickTask(), running: "click event: handleMixed()" }, + consoleOutput: [], + }, + { + descriptionHtml: + "Handler returns, React renders once. The queue runs from base state 3: replace with 8, updater turns 8 into 9, replace with 42. Final value 42, one render for three calls.", + activeLine: 2, + doneLines: [1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], + updateQueue: MIXED_APPLIED, + hooks: [hook("count", "3", "42")], + renderLog: [ + ...UPDATER_LOG_AFTER_TRIPLE, + render("r3", "Render #3", "queue 3 -> 8 -> 9 -> 42, count = 42"), + ], + eventLoop: reactFlush("React render: Counter() with mixed queue"), + consoleOutput: [], + }, + { + descriptionHtml: + "Commit. The span shows 42 and the queue is empty. Use an updater whenever the next state depends on the previous one; use a value when you are setting state to something you already know.", + activeLine: 17, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19], + updateQueue: [], + hooks: [hook("count", "42")], + renderLog: [ + ...UPDATER_LOG_AFTER_TRIPLE, + render("r3", "Render #3", "queue 3 -> 8 -> 9 -> 42, count = 42"), + commit("c3", "Commit #3", "DOM shows 42"), + ], + eventLoop: idle, + consoleOutput: [], + }, + ], +}; + +/* ── Example 3: automatic batching outside events ── */ + +const TIMER_PENDING = { label: "setTimeout callback (after 0 ms)", kind: "task" as const }; +const THEN_PENDING = { label: "promise .then callback", kind: "microtask" as const }; + +const AUTO_MOUNT_LOG: RenderLogEntry[] = [ + render("r1", "Render #1", "mount, count = 0, flag = false"), + commit("c1", "Commit #1", "DOM shows 0"), +]; + +const AUTO_LOG_AFTER_FLUSH: RenderLogEntry[] = [ + ...AUTO_MOUNT_LOG, + render("r2", "Render #2", "flushSync, count = 1"), + commit("c2", "Commit #2", "DOM shows 1, synchronous"), +]; + +const AUTO_LOG_AFTER_THEN: RenderLogEntry[] = [ + ...AUTO_LOG_AFTER_FLUSH, + legacy("l1", "extra render", "setCount in .then rendered on its own"), + legacy("l2", "extra render", "setFlag in .then rendered on its own"), + render("r3", "Render #3", "batched .then updates, count = 2, flag = true"), + commit("c3", "Commit #3", "DOM shows 2"), +]; + +const AUTO_LOG_FINAL: RenderLogEntry[] = [ + ...AUTO_LOG_AFTER_THEN, + legacy("l3", "extra render", "setCount in timeout rendered on its own"), + legacy("l4", "extra render", "setFlag in timeout rendered on its own"), + render("r4", "Render #4", "batched timeout updates, count = 3, flag = false"), + commit("c4", "Commit #4", "DOM shows 3"), +]; + +const automaticExample: StateBatchingExample = { + id: "automatic", + title: "Automatic batching outside events", + description: + "React 17 rendered once per setState inside timeouts and promises. React 18+ batches those too, and flushSync forces a render mid-handler when you need one.", + kind: "automatic", + codeLines: [ + { num: 1, text: "function Counter() {" }, + { num: 2, text: " const [count, setCount] = useState(0);" }, + { num: 3, text: " const [flag, setFlag] = useState(false);" }, + { num: 4, text: " function handleClick() {" }, + { num: 5, text: " setTimeout(() => {" }, + { num: 6, text: " setCount(c => c + 1);" }, + { num: 7, text: " setFlag(f => !f);" }, + { num: 8, text: " }, 0);" }, + { num: 9, text: " Promise.resolve().then(() => {" }, + { num: 10, text: " setCount(c => c + 1);" }, + { num: 11, text: " setFlag(f => !f);" }, + { num: 12, text: " });" }, + { num: 13, text: " flushSync(() => setCount(1));" }, + { num: 14, text: " console.log('after flushSync');" }, + { num: 15, text: " }" }, + { num: 16, text: " return ;" }, + { num: 17, text: "}" }, + ], + steps: [ + { + descriptionHtml: + "Mount render creates two hooks on the fiber: count = 0 and flag = false. The Render Log will count renders under React 17 (legacy root) and React 18+ (createRoot) side by side.", + activeLine: 3, + doneLines: [1, 2], + updateQueue: [], + hooks: [hook("count", "0"), hook("flag", "false")], + renderLog: AUTO_MOUNT_LOG, + renderTotals: { react17: 1, react18: 1 }, + eventLoop: reactFlush("React render: Counter() mount"), + consoleOutput: [], + }, + { + descriptionHtml: + "The user clicks. handleClick runs inside the click task, in React's batched event context. In both React 17 and 18, updates made directly in this handler would be batched.", + activeLine: 4, + doneLines: [1, 2, 3, 16], + updateQueue: [], + hooks: [hook("count", "0"), hook("flag", "false")], + renderLog: AUTO_MOUNT_LOG, + renderTotals: { react17: 1, react18: 1 }, + eventLoop: clickTask(), + consoleOutput: [], + }, + { + descriptionHtml: + "setTimeout hands the callback to the browser's timer. After 0 ms it will be placed on the task queue, to run in a later turn of the event loop, long after this handler has returned.", + activeLine: 5, + doneLines: [1, 2, 3, 4, 16], + updateQueue: [], + hooks: [hook("count", "0"), hook("flag", "false")], + renderLog: AUTO_MOUNT_LOG, + renderTotals: { react17: 1, react18: 1 }, + eventLoop: clickTask([TIMER_PENDING]), + consoleOutput: [], + }, + { + descriptionHtml: + "Promise.resolve().then(cb) is already resolved, so cb is queued as a microtask immediately. Microtasks run as soon as the current task finishes, so this callback will run before the timer callback.", + activeLine: 9, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 16], + updateQueue: [], + hooks: [hook("count", "0"), hook("flag", "false")], + renderLog: AUTO_MOUNT_LOG, + renderTotals: { react17: 1, react18: 1 }, + eventLoop: clickTask([THEN_PENDING, TIMER_PENDING]), + consoleOutput: [], + }, + { + descriptionHtml: + "flushSync runs its callback, which queues setCount(1), then forces React to render and commit synchronously before returning. Render #2 happens right here, in the middle of the handler, with count = 1.", + activeLine: 13, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16], + updateQueue: [value("f1", "count", "setCount(1)", "1", "applied", "0 -> 1")], + hooks: [hook("count", "0", "1"), hook("flag", "false")], + renderLog: [ + ...AUTO_MOUNT_LOG, + render("r2", "Render #2", "flushSync, count = 1"), + ], + renderTotals: { react17: 2, react18: 2 }, + eventLoop: reactFlush("React render: flushSync inside handleClick()", [ + THEN_PENDING, + TIMER_PENDING, + ]), + consoleOutput: [], + }, + { + descriptionHtml: + "flushSync has returned and the DOM already shows 1. console.log runs after the commit, which is the point of flushSync: use it when code right after the update needs the DOM to be current, such as measuring or scrolling.", + activeLine: 14, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 16], + updateQueue: [], + hooks: [hook("count", "1"), hook("flag", "false")], + renderLog: AUTO_LOG_AFTER_FLUSH, + renderTotals: { react17: 2, react18: 2 }, + eventLoop: clickTask([THEN_PENDING, TIMER_PENDING]), + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "The handler returns and the click task ends with nothing left in React's queue. The event loop now drains microtasks before taking another task, so the .then callback is next.", + activeLine: 15, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16], + updateQueue: [], + hooks: [hook("count", "1"), hook("flag", "false")], + renderLog: AUTO_LOG_AFTER_FLUSH, + renderTotals: { react17: 2, react18: 2 }, + eventLoop: { + running: null, + runningKind: "idle", + pending: [THEN_PENDING, TIMER_PENDING], + }, + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "The microtask runs, outside any React event handler. setCount(c => c + 1) queues an updater. React 17 has no batching context here, so it renders synchronously inside this call. React 18 queues the update and schedules one flush for after the callback.", + activeLine: 10, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 13, 14, 15, 16], + updateQueue: [updater("p1", "count", "setCount(c => c + 1)", "c => c + 1")], + hooks: [hook("count", "1"), hook("flag", "false")], + renderLog: [ + ...AUTO_LOG_AFTER_FLUSH, + legacy("l1", "extra render", "setCount in .then rendered on its own"), + ], + renderTotals: { react17: 3, react18: 2 }, + eventLoop: { + running: "promise .then callback", + runningKind: "microtask", + pending: [TIMER_PENDING], + }, + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "setFlag(f => !f) queues an updater on the second hook. React 17 renders again, a second render for this callback. React 18 appends to the pending work; the flush it already scheduled will pick up both hooks at once.", + activeLine: 11, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16], + updateQueue: [ + updater("p1", "count", "setCount(c => c + 1)", "c => c + 1"), + updater("p2", "flag", "setFlag(f => !f)", "f => !f"), + ], + hooks: [hook("count", "1"), hook("flag", "false")], + renderLog: [ + ...AUTO_LOG_AFTER_FLUSH, + legacy("l1", "extra render", "setCount in .then rendered on its own"), + legacy("l2", "extra render", "setFlag in .then rendered on its own"), + ], + renderTotals: { react17: 4, react18: 2 }, + eventLoop: { + running: "promise .then callback", + runningKind: "microtask", + pending: [TIMER_PENDING], + }, + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "The callback returns and React 18's scheduled flush runs. One render processes both queues: count 1 -> 2, flag false -> true. One commit instead of two. This is automatic batching: the batch boundary is the end of the callback, not the event handler.", + activeLine: 2, + doneLines: [1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + updateQueue: [ + updater("p1", "count", "setCount(c => c + 1)", "c => c + 1", "applied", "1 -> 2"), + updater("p2", "flag", "setFlag(f => !f)", "f => !f", "applied", "false -> true"), + ], + hooks: [hook("count", "1", "2"), hook("flag", "false", "true")], + renderLog: AUTO_LOG_AFTER_THEN, + renderTotals: { react17: 4, react18: 3 }, + eventLoop: reactFlush("React flush: batched .then updates", [TIMER_PENDING]), + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "Microtasks are drained, so the event loop takes the timer task. setCount(c => c + 1) queues an updater. Again React 17 renders immediately while React 18 schedules a single flush.", + activeLine: 6, + doneLines: [1, 2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16], + updateQueue: [updater("s1", "count", "setCount(c => c + 1)", "c => c + 1")], + hooks: [hook("count", "2"), hook("flag", "true")], + renderLog: [ + ...AUTO_LOG_AFTER_THEN, + legacy("l3", "extra render", "setCount in timeout rendered on its own"), + ], + renderTotals: { react17: 5, react18: 3 }, + eventLoop: { + running: "setTimeout callback", + runningKind: "task", + pending: [], + }, + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "setFlag(f => !f) queues the second updater. React 17 is now on its sixth render in total, with intermediate states the user never needed to see. React 18 is still holding two updates for one pass.", + activeLine: 7, + doneLines: [1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16], + updateQueue: [ + updater("s1", "count", "setCount(c => c + 1)", "c => c + 1"), + updater("s2", "flag", "setFlag(f => !f)", "f => !f"), + ], + hooks: [hook("count", "2"), hook("flag", "true")], + renderLog: [ + ...AUTO_LOG_AFTER_THEN, + legacy("l3", "extra render", "setCount in timeout rendered on its own"), + legacy("l4", "extra render", "setFlag in timeout rendered on its own"), + ], + renderTotals: { react17: 6, react18: 3 }, + eventLoop: { + running: "setTimeout callback", + runningKind: "task", + pending: [], + }, + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "The timer callback returns and React 18 flushes: count 2 -> 3, flag true -> false in one render and one commit. Total for this click: React 17 rendered 6 times, React 18 rendered 4 times, and the DOM ended up identical.", + activeLine: 2, + doneLines: [1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + updateQueue: [ + updater("s1", "count", "setCount(c => c + 1)", "c => c + 1", "applied", "2 -> 3"), + updater("s2", "flag", "setFlag(f => !f)", "f => !f", "applied", "true -> false"), + ], + hooks: [hook("count", "2", "3"), hook("flag", "true", "false")], + renderLog: AUTO_LOG_FINAL, + renderTotals: { react17: 6, react18: 4 }, + eventLoop: reactFlush("React flush: batched timeout updates"), + consoleOutput: ["after flushSync"], + }, + { + descriptionHtml: + "Everything is committed and the event loop is idle. The rule in React 18+: updates are batched per callback wherever they come from, and flushSync is the explicit opt-out when you need the DOM updated before the next line runs.", + activeLine: 16, + doneLines: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + updateQueue: [], + hooks: [hook("count", "3"), hook("flag", "false")], + renderLog: AUTO_LOG_FINAL, + renderTotals: { react17: 6, react18: 4 }, + eventLoop: idle, + consoleOutput: ["after flushSync"], + }, + ], +}; + +void note; + +export const EXAMPLES: StateBatchingExample[] = [ + batchExample, + updaterExample, + automaticExample, +]; diff --git a/src/components/visualizations/state-batching/helpers.ts b/src/components/visualizations/state-batching/helpers.ts index cb0ff5c..9a5db5b 100644 --- a/src/components/visualizations/state-batching/helpers.ts +++ b/src/components/visualizations/state-batching/helpers.ts @@ -1 +1,72 @@ -export {}; +import { + createKindBadgeClass, + createKindLabel, +} from "@/lib/visualization-helpers"; +import type { + EventLoopRunKind, + RenderLogKind, + StateBatchingKind, + UpdateStatus, +} from "./types"; + +export const kindBadgeClass = createKindBadgeClass({ + batch: "bg-cyan-500/15 text-cyan-400 border-cyan-500/25", + updater: "bg-violet-500/15 text-violet-400 border-violet-500/25", + automatic: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25", +}); + +export const kindLabel = createKindLabel({ + batch: "one render", + updater: "updater queue", + automatic: "automatic batching", +}); + +const UPDATE_STATUS_STYLES: Record = { + queued: "border-amber-500/30 bg-amber-500/8 text-amber-200", + applying: "border-cyan-400/40 bg-cyan-500/10 text-cyan-200 shadow-[0_0_14px_rgba(34,211,238,0.12)]", + applied: "border-emerald-500/30 bg-emerald-500/8 text-emerald-200 opacity-70", +}; + +export const updateStatusStyle = (status: UpdateStatus) => + UPDATE_STATUS_STYLES[status]; + +const RENDER_LOG_STYLES: Record = { + render: "border-cyan-500/30 bg-cyan-500/8 text-cyan-200", + commit: "border-emerald-500/30 bg-emerald-500/8 text-emerald-200", + legacy: "border-rose-500/25 bg-rose-500/6 text-rose-200/80", + note: "border-slate-500/30 bg-slate-500/8 text-slate-300", +}; + +const RENDER_LOG_TAGS: Record = { + render: "RENDER", + commit: "COMMIT", + legacy: "REACT 17", + note: "NOTE", +}; + +export const renderLogStyle = (kind: RenderLogKind) => RENDER_LOG_STYLES[kind]; +export const renderLogTag = (kind: RenderLogKind) => RENDER_LOG_TAGS[kind]; + +const RUN_KIND_STYLES: Record = { + idle: "border-slate-500/30 bg-slate-800/40 text-slate-400", + task: "border-emerald-400/40 bg-emerald-500/10 text-emerald-200", + microtask: "border-violet-400/40 bg-violet-500/10 text-violet-200", + react: "border-cyan-400/40 bg-cyan-500/10 text-cyan-200", +}; + +const RUN_KIND_LABELS: Record = { + idle: "idle", + task: "task", + microtask: "microtask", + react: "react flush", +}; + +export const runKindStyle = (kind: EventLoopRunKind) => RUN_KIND_STYLES[kind]; +export const runKindLabel = (kind: EventLoopRunKind) => RUN_KIND_LABELS[kind]; + +const PENDING_STYLES: Record<"task" | "microtask", string> = { + task: "border-emerald-500/25 text-emerald-300/80", + microtask: "border-violet-500/25 text-violet-300/80", +}; + +export const pendingStyle = (kind: "task" | "microtask") => PENDING_STYLES[kind]; diff --git a/src/components/visualizations/state-batching/index.tsx b/src/components/visualizations/state-batching/index.tsx index 6fc5c0e..9c8c9c6 100644 --- a/src/components/visualizations/state-batching/index.tsx +++ b/src/components/visualizations/state-batching/index.tsx @@ -1,14 +1,219 @@ "use client"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { NeonPanel } from "@/components/visualization-ui/NeonPanel"; +import { + CodeBlock, + type CodeBlockLine, +} from "@/components/visualization-ui/CodeBlock"; +import { ConsoleOutput } from "@/components/visualization-ui/ConsoleOutput"; +import { TransportControls } from "@/components/visualization-ui/TransportControls"; import { ExampleSelector } from "@/components/visualization-ui/ExampleSelector"; +import { ToolbarPortal } from "@/components/layout/ToolbarPortal"; +import { cn } from "@/lib/utils"; +import { + VISUALIZATION_PANEL_TITLES, + VISUALIZATION_EMPTY_STATES, +} from "@/lib/visualization/uiCopy"; +import { useStepPlayback } from "@/hooks/useStepPlayback"; +import { useChangeFlash } from "@/hooks/useChangeFlash"; +import { EXAMPLES } from "./data"; +import { kindBadgeClass, kindLabel } from "./helpers"; +import { UpdateQueuePanel } from "./components/UpdateQueuePanel"; +import { HookStatePanel } from "./components/HookStatePanel"; +import { RenderLogPanel } from "./components/RenderLogPanel"; +import { EventLoopPanel } from "./components/EventLoopPanel"; -// STUB: replaced by the real implementation. Keeps the build green while -// topics are authored in parallel. export const StateBatching = () => { - void ExampleSelector; + const [activeExampleId, setActiveExampleId] = useState(EXAMPLES[0].id); + + const handleExampleChange = (id: string) => { + setActiveExampleId(id); + }; + + const example = + EXAMPLES.find((e) => e.id === activeExampleId) ?? EXAMPLES[0]; + + const { + currentStepIndex, + isPlaying, + speedLevel, + speedLabel, + canStep, + canStepBack, + togglePlay, + step: handleStep, + stepBack: handleStepBack, + reset: handleReset, + setSpeedLevel, + jumpTo, + } = useStepPlayback({ + totalSteps: example.steps.length, + initialStep: -1, + resetKey: activeExampleId, + }); + + const currentStep = + currentStepIndex >= 0 ? example.steps[currentStepIndex] : null; + + const flashes = useChangeFlash( + { + description: currentStep?.descriptionHtml, + updateQueue: currentStep?.updateQueue, + hooks: currentStep?.hooks, + renderLog: currentStep?.renderLog, + eventLoop: currentStep?.eventLoop, + console: currentStep?.consoleOutput, + }, + currentStepIndex, + ); + return ( -

    - State Batching visualization coming soon. -

    + <> + +
    +
    +
    + ( + + {kindLabel(ex.kind)} + + )} + /> + + {kindLabel(example.kind)} + +
    + + +
    + +
    + {currentStep?.descriptionHtml ? ( +

    + ) : ( +

    + {VISUALIZATION_EMPTY_STATES.stepDescription} +

    + )} +
    +
    +
    + +
    +
    + + { + const isActive = currentStep?.activeLine === line.num; + const isDone = + currentStep?.doneLines.includes(line.num) ?? false; + return { + key: line.num, + lineNumber: line.num, + text: line.text, + className: cn( + isActive && "is-active", + isDone && !isActive && "is-done", + ), + }; + })} + /> + + +
    +
    + + + + + + + + + + + + + + + +
    + +
    + +
    +
    +
    +
    + ); }; diff --git a/src/components/visualizations/state-batching/types.ts b/src/components/visualizations/state-batching/types.ts index cb0ff5c..da6733e 100644 --- a/src/components/visualizations/state-batching/types.ts +++ b/src/components/visualizations/state-batching/types.ts @@ -1 +1,73 @@ -export {}; +import type { SourceLine } from "@/types/visualization"; +import type { ExampleOption } from "@/components/visualization-ui/ExampleSelector"; + +export type StateBatchingKind = "batch" | "updater" | "automatic"; + +export type UpdateStatus = "queued" | "applying" | "applied"; + +export interface QueuedUpdate { + id: string; + /** Hook the update belongs to, e.g. "count" */ + hook: string; + /** The call as written, e.g. "setCount(count + 1)" */ + call: string; + /** What was actually stored on the queue, e.g. "1" or "c => c + 1" */ + payload: string; + payloadKind: "value" | "updater"; + status: UpdateStatus; + /** Filled in once processed, e.g. "0 -> 1" */ + result?: string; +} + +export interface HookSnapshot { + name: string; + /** Value the last committed render saw */ + current: string; + /** Value computed from the queue during the in-progress render */ + pending: string | null; +} + +export type RenderLogKind = "render" | "commit" | "legacy" | "note"; + +export interface RenderLogEntry { + id: string; + kind: RenderLogKind; + label: string; + detail: string; +} + +export interface RenderTotals { + react17: number; + react18: number; +} + +export type EventLoopRunKind = "idle" | "task" | "microtask" | "react"; + +export interface PendingWork { + label: string; + kind: "task" | "microtask"; +} + +export interface EventLoopSnapshot { + running: string | null; + runningKind: EventLoopRunKind; + pending: PendingWork[]; +} + +export interface StateBatchingStep { + descriptionHtml: string; + activeLine: number | null; + doneLines: number[]; + updateQueue: QueuedUpdate[]; + hooks: HookSnapshot[]; + renderLog: RenderLogEntry[]; + renderTotals?: RenderTotals; + eventLoop: EventLoopSnapshot; + consoleOutput: string[]; +} + +export interface StateBatchingExample extends ExampleOption { + kind: StateBatchingKind; + codeLines: SourceLine[]; + steps: StateBatchingStep[]; +} diff --git a/src/content/theory/backend/caching-strategies.ts b/src/content/theory/backend/caching-strategies.ts index 06387a4..92745fb 100644 --- a/src/content/theory/backend/caching-strategies.ts +++ b/src/content/theory/backend/caching-strategies.ts @@ -1,11 +1,111 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const cachingStrategiesTheory: TopicTheoryContent = { - summary: "Caching Strategies theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], - relatedTopicIds: ["database-indexing", "http-request-lifecycle", "rate-limiting", "garbage-collection"], + summary: + "A cache keeps a copy of expensive data somewhere cheaper to read, so repeated requests skip the database or origin. The strategy decides who fills the cache, when it is updated, and what happens when the copy and the source disagree.", + whatItIs: [ + "Every request that reaches a database pays for disk reads, query planning, and a network hop to a machine that all other requests are also hitting. A cache, whether an in-process Map, a Redis instance, or a CDN edge, stores the result of that work so the next request for the same key returns in a millisecond or two instead of a hundred. Caches buy two things at once: lower latency for the caller and lower load on the source.", + "The strategy is the set of rules that connects the cache to the source of truth. Cache-aside puts the application in charge: check the cache, fall back to the database on a miss, write the result back with a TTL. Write-through updates the database and the cache in the same operation, so reads always see the latest write. Write-behind updates the cache immediately and queues the database write, which makes writes fast but leaves a window where the queue is the only copy.", + "Every copy can drift from its source, and the two hard problems follow from that. Invalidation is deciding which keys a write affects and removing them, which requires the writing code to know every cached shape of the data. Stale reads are what happens when invalidation is missed: the cache answers quickly with data the database no longer holds. A TTL bounds how long that can last, which is why even a well-invalidated cache still sets one.", + "Caches are bounded, so a full cache must evict. LRU removes the key that has gone longest without a read, on the bet that recent use predicts future use. Redis approximates it by sampling a few keys rather than tracking a full list, and offers LFU for workloads where one-off reads would otherwise flush the hot set. HTTP caching applies the same ideas one layer out: Cache-Control max-age is the TTL, and an ETag revalidation is the check that a cached copy still matches the origin.", + ], + howItWorks: [ + "Step 1: the application builds a cache key from the request, for example user:42, and asks the cache for it. The key is the only thing the cache and the database share, so it must be derived identically on every read and write path.", + "Step 2: on a hit the cached bytes are deserialized and returned, typically in 1 to 2 ms. The database never sees the request.", + "Step 3: on a miss the application queries the source, serializes the row, and stores it with a TTL such as EX 60. A cold miss costs the cache round trip plus the full query, so it is slightly slower than having no cache.", + "Step 4: when the TTL elapses the cache drops the key. If many requests arrive at that instant they all miss and run the same query, the thundering herd. A per-key lock, jittered TTLs, or serving the stale value while one request revalidates keeps the herd off the database.", + "Step 5: a write updates the source of truth, then either deletes the cached key (cache-aside), overwrites it in the same operation (write-through), or updates it first and queues the source write for a background flush (write-behind).", + "Step 6: when the cache reaches capacity it evicts. An LRU built on a Map deletes and re-inserts a key on every read so the first key in iteration order is always the least recently used, and set removes that key before inserting a new one.", + ], + commonMistakes: [ + { + title: "Writing to the database without invalidating", + explanation: + "The update path was written separately from the read path, so it commits the row and forgets that a cached copy exists. Every read until the TTL expires returns the old value, fast and wrong.", + fix: "Delete the cached key right after the write commits, and prefer delete over overwrite so the next reader rebuilds the entry from the row the database actually has.", + }, + { + title: "Caching without a TTL", + explanation: + "An entry with no expiry survives every missed invalidation forever, and a cache full of keys that are never read again still consumes memory until eviction pressure removes them.", + fix: "Always set a TTL that bounds how stale an entry can be, even when invalidation is in place. Treat the TTL as the safety net, not the primary consistency mechanism.", + }, + { + title: "Using write-behind for data that cannot be lost", + explanation: + "Write-behind confirms the write to the caller before any durable store has seen it. A crash before the queue drains loses every pending write, and once the cache restarts there is no trace of them.", + fix: "Reserve write-behind for counters, view tallies, and session touches. For anything a user would notice losing, use write-through or a durable queue such as Redis Streams or Kafka in front of the flush.", + }, + { + title: "Treating an LRU read as free", + explanation: + "Every hit moves the key to the most recent position. A one-off scan that reads every key once pushes the genuinely hot entries to the least recent end and evicts them on the next insert.", + fix: "Keep bulk scans and exports away from the application cache, size the cache for the working set rather than the dataset, or switch to an LFU policy when frequency predicts reuse better than recency.", + }, + ], + interviewQuestions: [ + { + question: "What is cache-aside and what does a miss cost?", + answer: + "Cache-aside means the application owns the lookup: it checks the cache, queries the database on a miss, and writes the result back with a TTL. A miss costs the cache round trip plus the full query, so a cold cache is marginally slower than no cache. The saving is deferred to every later read that hits.", + codeExample: { + code: `async function getUser(id) { + const key = \`user:\${id}\`; + const cached = await redis.get(key); + if (cached) return JSON.parse(cached); + const row = await db.query('SELECT * FROM users WHERE id = $1', [id]); + await redis.set(key, JSON.stringify(row), 'EX', 60); + return row; +}`, + }, + }, + { + question: "What is the difference between write-through and write-behind?", + answer: + "Write-through updates the database and the cache in the same request, so writes pay database latency but reads always see the latest value. Write-behind updates the cache and queues the database write for a background flush. Writes return in a couple of milliseconds and bursts can batch, but a crash before the flush loses the queued writes.", + }, + { + question: "Why is cache invalidation considered hard?", + answer: + "The code that writes has to know every cached key the write affects, and those keys may be shaped differently from the row: a user profile, a list page that includes the user, an aggregate count. Missing one leaves a stale copy that is served quickly and confidently until its TTL expires. Deleting the key rather than overwriting it, and keeping the set of cached shapes small, limits the damage.", + }, + { + question: "How would you implement an LRU cache in JavaScript?", + answer: + "Use a Map, which iterates in insertion order. On get, delete and re-insert the key so it becomes the newest entry. On set of a new key when the map is full, delete the first key from map.keys(), which is the least recently used, then insert. Both operations are O(1).", + codeExample: { + code: `class LRUCache { + constructor(capacity) { + this.capacity = capacity; + this.map = new Map(); + } + get(key) { + if (!this.map.has(key)) return undefined; + const value = this.map.get(key); + this.map.delete(key); + this.map.set(key, value); + return value; + } + set(key, value) { + if (this.map.has(key)) this.map.delete(key); + else if (this.map.size >= this.capacity) { + this.map.delete(this.map.keys().next().value); + } + this.map.set(key, value); + } +}`, + }, + }, + { + question: "What is a thundering herd and how do you prevent it?", + answer: + "When a popular key expires, every concurrent request misses at the same moment and all of them run the same query against the database. Mitigations include a per-key lock so only one caller refills while the rest wait, adding jitter to TTLs so keys do not expire together, and serving the stale value while a single request revalidates in the background.", + }, + ], + relatedTopicIds: [ + "database-indexing", + "http-request-lifecycle", + "rate-limiting", + "garbage-collection", + ], }; diff --git a/src/content/theory/backend/jwt-authentication.ts b/src/content/theory/backend/jwt-authentication.ts index 5c56ed5..d908478 100644 --- a/src/content/theory/backend/jwt-authentication.ts +++ b/src/content/theory/backend/jwt-authentication.ts @@ -1,11 +1,104 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const jwtAuthenticationTheory: TopicTheoryContent = { - summary: "JWT Authentication theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "A JSON Web Token is a signed, base64url-encoded set of claims that a server issues at login and verifies on every later request without a database lookup, so authentication state lives in the token instead of in a session store.", + whatItIs: [ + "After a user proves who they are with a password, the server needs a way to recognise them on the next request. A JWT solves this by handing the client a small string that says who the user is and until when, plus a signature that lets the server confirm it wrote that string. The client sends the string back with each request and the server checks the signature instead of asking a database.", + "A JWT (RFC 7519) has three parts separated by dots: a header that names the signing algorithm, a payload of claims such as sub (subject), iat (issued at) and exp (expires at), and a signature. The first two parts are JSON encoded with base64url, which is ordinary base64 with a URL-safe alphabet and no padding. For HS256 the signature is HMAC-SHA256 over the string header.payload, keyed with a secret only the server holds.", + "Signing proves integrity and origin: if any byte of the header or payload changes, the recomputed HMAC no longer matches, and nobody without the secret can produce a matching one. It does not provide secrecy. The payload is readable by anyone holding the token, so it must never contain passwords, secrets or personal data you would not print in a log. Asymmetric algorithms such as RS256 sign with a private key and verify with a public one, which lets other services verify tokens without being able to mint them.", + "The trade-off of stateless verification is that the server cannot forget a token it already signed. An access token stays valid until exp, whatever happens in between. Production systems therefore keep access tokens short, on the order of minutes, and pair them with a long-lived refresh token that is stored server side and can be rotated and revoked.", + ], + howItWorks: [ + "Step 1: the client sends credentials to a login endpoint. The server looks the user up, compares the password with the stored hash, and builds the claims: sub, any application data such as a role, iat set to the current Unix time in seconds, and exp a fixed interval later.", + "Step 2: the server base64url-encodes the JSON header and the JSON payload, computes HMAC-SHA256 over header.payload with its secret, base64url-encodes the digest, and joins the three parts with dots. It returns the token and stores nothing about it.", + "Step 3: on a protected request the client presents the token, usually as Authorization: Bearer . Middleware splits the token on the dots and recomputes the HMAC over the first two parts with the same secret, always using the algorithm the server expects rather than the one named in the header.", + "Step 4: the recomputed digest is compared with the presented one using a constant-time comparison so response timing does not leak which byte differed. A mismatch means the token was forged or edited and the request is rejected with 401 before any claim is read.", + "Step 5: once the signature passes, the payload is decoded and the time claims are checked: exp must be in the future and nbf, if present, must be in the past. A token whose signature is valid but whose exp has passed is rejected as expired.", + "Step 6: the verified claims are attached to the request, for example as req.user, and the route handler uses them for authorization. When the access token expires, the client presents its refresh token, the server checks that token against its allow-list, rotates it, and issues a new access token; logout revokes the refresh token.", + ], + commonMistakes: [ + { + title: "Treating the payload as private", + explanation: + "Base64url looks opaque, so developers put email addresses, internal ids, or even password hashes in the payload. Anyone who intercepts or is handed the token can decode it with a single function call.", + fix: "Put only what the server needs to authorize the request in the claims, treat every claim as public, and use encryption (JWE) or a server-side lookup if data must stay secret.", + }, + { + title: "Letting the header choose the algorithm", + explanation: + "A verifier that reads alg from the token and dispatches on it can be tricked with alg none, which skips signing, or with an RS256 public key reused as an HS256 secret. Both let an attacker forge tokens the server accepts.", + fix: "Hard-code the expected algorithm and key in the verifier and reject any token whose header does not match it.", + }, + { + title: "Expecting logout to invalidate access tokens", + explanation: + "Because verification is stateless, a signed access token keeps working until exp even after the user logs out or has their permissions removed. Long-lived access tokens turn this into a real security gap.", + fix: "Keep access tokens short, revoke the refresh token on logout, and for sensitive actions re-check the database or a denylist keyed by the jti claim.", + }, + { + title: "Storing tokens in localStorage without weighing XSS", + explanation: + "localStorage is readable by any script running on the page, so a single XSS vulnerability hands the attacker a valid token. httpOnly cookies avoid that but are sent automatically, which reopens CSRF.", + fix: "Keep the access token in memory and the refresh token in an httpOnly, secure, sameSite cookie scoped to the refresh path, and add CSRF protection wherever cookies carry auth.", + }, + ], + interviewQuestions: [ + { + question: "What are the three parts of a JWT and what does each contain?", + answer: + "The header is JSON naming the algorithm and token type, such as alg HS256 and typ JWT. The payload is JSON holding claims like sub, iat, exp and application data. The signature is a MAC or digital signature over the base64url-encoded header and payload joined by a dot. All three are base64url-encoded and joined with dots.", + codeExample: { + language: "javascript", + code: `const { createHmac } = require("crypto"); +const b64url = (s) => Buffer.from(s).toString("base64url"); + +function sign(payload, secret) { + const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const body = b64url(JSON.stringify(payload)); + const sig = createHmac("sha256", secret) + .update(\`\${header}.\${body}\`) + .digest("base64url"); + return \`\${header}.\${body}.\${sig}\`; +}`, + }, + }, + { + question: "Does a JWT encrypt its contents?", + answer: + "No. A signed JWT (JWS) is encoded, not encrypted, and anyone holding it can decode the claims. The signature only guarantees the claims were not altered and were produced by a holder of the key. Confidential data needs JWE or must stay on the server.", + }, + { + question: "How does the server verify a token, and why does it compare signatures in constant time?", + answer: + "It splits the token, recomputes the HMAC over header.payload with its own secret and algorithm, and compares the result with the presented signature. A naive string comparison returns as soon as one byte differs, so an attacker can measure response times to discover the correct signature byte by byte. Comparing all bytes regardless of where they differ removes that side channel.", + codeExample: { + language: "javascript", + code: `function verify(token, secret) { + const [header, body, sig] = token.split("."); + const expected = createHmac("sha256", secret) + .update(\`\${header}.\${body}\`) + .digest("base64url"); + if (expected.length !== sig.length || + !timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) { + throw new Error("invalid signature"); + } + const payload = JSON.parse(Buffer.from(body, "base64url").toString()); + if (payload.exp <= Math.floor(Date.now() / 1000)) throw new Error("token expired"); + return payload; +}`, + }, + }, + { + question: "What is the difference between HS256 and RS256, and when would you pick each?", + answer: + "HS256 uses one shared secret for both signing and verification, so every service that verifies tokens could also forge them. RS256 signs with a private key and verifies with the matching public key, so verification can be spread across services or third parties without sharing signing power. HS256 is simpler and faster for a single backend; RS256 fits multi-service or federated setups.", + }, + { + question: "Why use refresh tokens if access tokens already carry an expiry?", + answer: + "An access token cannot be revoked before exp because the server holds no state for it, so it must be short-lived, which would force frequent logins on its own. A refresh token is long-lived but stored server side, so it can be checked, rotated on every use, and revoked at logout or on suspected theft. The pair gives fast stateless checks on most requests and a revocable anchor for the session.", + }, + ], relatedTopicIds: ["http-request-lifecycle", "rate-limiting", "caching-strategies"], }; diff --git a/src/content/theory/react/state-batching.ts b/src/content/theory/react/state-batching.ts index 2509831..e418733 100644 --- a/src/content/theory/react/state-batching.ts +++ b/src/content/theory/react/state-batching.ts @@ -1,11 +1,98 @@ import type { TopicTheoryContent } from "@/content/theory/types"; -// STUB: replaced by the real content. export const stateBatchingTheory: TopicTheoryContent = { - summary: "State Batching theory is being written.", - whatItIs: ["Coming soon."], - howItWorks: ["Step 1: coming soon."], - commonMistakes: [], - interviewQuestions: [], + summary: + "State batching is how React collects every setState call made during one event handler or callback into a single render, so the UI updates once with the final result instead of once per call.", + whatItIs: [ + "Calling setState does not change anything immediately. It records an update on the hook's queue and asks React to render later. Batching is the rule for when \"later\" is: React waits until the current handler or callback finishes, then processes every queued update in one render pass and commits once.", + "Each render captures its state as a snapshot. The count variable inside an event handler is a plain constant captured by a closure, so three setCount(count + 1) calls in a row all compute the same number. To build on the pending value instead of the snapshot, you pass an updater function, setCount(c => c + 1), and React calls it with the result of the previous queued update.", + "Before React 18, batching only applied inside React's own event handlers. Updates made in a setTimeout, a promise callback, or a native event listener each rendered synchronously on their own. React 18 introduced automatic batching for roots created with createRoot: updates are batched wherever they come from, with the batch boundary at the end of the callback that issued them.", + "This design matters because rendering is the expensive part. One render per tick keeps intermediate states off the screen, avoids running effects for values nobody sees, and lets React treat the update queue as data it can reorder by priority under concurrent rendering. flushSync exists as the explicit escape hatch for the rare case where the DOM must be updated before the next line of code runs.", + ], + howItWorks: [ + "Step 1: a component renders and useState stores its current value in the hook object on the fiber. The event handlers created during that render close over the snapshot value.", + "Step 2: an event fires. React runs the handler inside a batched context, so any setState call appends an update object to the hook's queue, marks the fiber as needing work, and returns without rendering.", + "Step 3: each update records either a value or an updater function. A value replaces the state when its turn comes; an updater is stored uncalled and receives the pending state at that position in the queue.", + "Step 4: the handler returns. React flushes the scheduled work: it calls the component again, and useState walks the queue in call order from the base state to compute the new memoizedState.", + "Step 5: the render produces one new element tree and React commits it once. The DOM reflects the final state, the queue is cleared, and the new render creates fresh closures with the updated snapshot.", + "Step 6: outside event handlers, React 18+ applies the same rule per callback. A microtask or timer callback that calls setState several times still produces a single flush when that callback ends. flushSync forces the flush to happen synchronously inside the call instead.", + ], + commonMistakes: [ + { + title: "Expecting the next line to see the new state", + explanation: + "Logging count right after setCount prints the old value. The handler's count is a closure over the render that created it, and the render that would produce the new value has not run yet.", + fix: "Compute the next value in a local variable and use that, or move logic that depends on the committed state into a useEffect keyed on that state.", + }, + { + title: "Calling setCount(count + 1) repeatedly", + explanation: + "Every call reads the same snapshot, so three calls queue three updates with the same payload and the final state increments by one.", + fix: "Use the updater form, setCount(c => c + 1), whenever the next state depends on the previous state. React feeds each updater the result of the one before it.", + }, + { + title: "Assuming timeouts and promises batch on every React version", + explanation: + "On a React 17 legacy root, or a React 18 app still mounted with ReactDOM.render, each setState inside a setTimeout or .then callback triggers its own synchronous render.", + fix: "Mount with createRoot to get automatic batching everywhere. If a component still needs several updates to land together on an older root, wrap them in unstable_batchedUpdates or combine them into one state object.", + }, + { + title: "Reaching for flushSync to fix ordering bugs", + explanation: + "flushSync forces a synchronous render and commit in the middle of the handler, which defeats batching and can noticeably hurt performance when overused.", + fix: "Reserve flushSync for cases where the DOM must be current before the next statement, such as measuring a node or scrolling to a freshly inserted item. Everything else belongs in effects or derived state.", + }, + ], + interviewQuestions: [ + { + question: "Why does this handler leave count at 1 instead of 3?", + answer: + "count is a constant captured when the component rendered with value 0. All three calls evaluate count + 1 to 1 and queue three replace-with-1 updates. React processes them in one render and the result is 1.", + codeExample: { + language: "jsx", + code: `function handleClick() { + setCount(count + 1); // queues 1 + setCount(count + 1); // queues 1 + setCount(count + 1); // queues 1 +} +// one render, count === 1`, + }, + }, + { + question: "How does the updater form differ from passing a value?", + answer: + "A value update replaces the state outright when React reaches it in the queue. An updater is stored as a function and called during the render with the pending state at that point, so consecutive updaters chain: 0 to 1 to 2 to 3. Mixing them still runs in call order, so a trailing value overwrites whatever the updaters computed.", + codeExample: { + language: "jsx", + code: `setCount(count + 5); // queue: replace with 5 (count was 0) +setCount(c => c + 1); // queue: 5 -> 6 +setCount(42); // queue: replace with 42 +// one render, count === 42`, + }, + }, + { + question: "What changed about batching in React 18?", + answer: + "React 17 batched updates only inside React event handlers; updates in timeouts, promises, and native listeners each rendered on their own. React 18 with createRoot batches automatically in every callback, so two setState calls inside a .then produce one render instead of two.", + }, + { + question: "When would you use flushSync, and what does it cost?", + answer: + "flushSync runs its callback, then synchronously renders and commits any updates it queued before returning, so code after the call can read the updated DOM. It bypasses batching for that update and blocks the main thread for the render, so it should be limited to layout measurement or scroll positioning after a state change.", + codeExample: { + language: "jsx", + code: `flushSync(() => { + setItems([...items, newItem]); +}); +// DOM already has the new row here +listRef.current.lastChild.scrollIntoView();`, + }, + }, + { + question: "How does batching relate to the event loop?", + answer: + "React does not use a timer to batch. Inside an event handler it holds updates until the handler returns and then flushes synchronously in the same task. For updates issued from a microtask or timer callback, React 18 schedules a flush that runs once that callback finishes, so the batch boundary lines up with a single unit of event loop work.", + }, + ], relatedTopicIds: ["hooks", "render-cycle", "concurrent-rendering", "event-loop"], }; From 5251f0079568b8266b8132906e92c91c1f2b9c7d Mon Sep 17 00:00:00 2001 From: tornike14 Date: Fri, 18 Sep 2026 13:56:37 -0400 Subject: [PATCH 05/11] Add async/await topic and cap the JWT source panel width --- .../async-await/components/FunctionCards.tsx | 50 ++ .../async-await/components/QueueList.tsx | 35 + .../async-await/components/TimelineBars.tsx | 60 ++ .../async-await/components/TimerList.tsx | 33 + .../visualizations/async-await/data.ts | 723 +++++++++++++++++- .../visualizations/async-await/helpers.ts | 59 +- .../visualizations/async-await/index.tsx | 218 +++++- .../visualizations/async-await/types.ts | 59 +- .../jwt-authentication/index.tsx | 1 + src/content/theory/javascript/async-await.ts | 111 ++- 10 files changed, 1334 insertions(+), 15 deletions(-) create mode 100644 src/components/visualizations/async-await/components/FunctionCards.tsx create mode 100644 src/components/visualizations/async-await/components/QueueList.tsx create mode 100644 src/components/visualizations/async-await/components/TimelineBars.tsx create mode 100644 src/components/visualizations/async-await/components/TimerList.tsx diff --git a/src/components/visualizations/async-await/components/FunctionCards.tsx b/src/components/visualizations/async-await/components/FunctionCards.tsx new file mode 100644 index 0000000..847659f --- /dev/null +++ b/src/components/visualizations/async-await/components/FunctionCards.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils"; +import { FN_CARD_STYLES, FN_STATUS_STYLES, PROMISE_STATE_STYLES } from "../helpers"; +import type { AsyncFnState } from "../types"; + +export const FunctionCards = ({ functions }: { functions: AsyncFnState[] }) => { + if (functions.length === 0) { + return ( +

    + no async calls yet +

    + ); + } + + return ( +
    + {functions.map((fn) => ( +
    +
    + + {fn.name} + + + {fn.status} + +
    + {fn.status === "suspended" && fn.suspendedAt !== null && ( +
    + parked at line {fn.suspendedAt} +
    + )} +
    + {fn.promise.name} + + {fn.promise.state} + + {fn.promise.value} +
    +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/async-await/components/QueueList.tsx b/src/components/visualizations/async-await/components/QueueList.tsx new file mode 100644 index 0000000..6307556 --- /dev/null +++ b/src/components/visualizations/async-await/components/QueueList.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; +import { QUEUE_ITEM_STYLES } from "../helpers"; +import type { QueueTone } from "../types"; + +interface QueueListProps { + items: string[]; + tone: QueueTone; + emptyLabel?: string; +} + +export const QueueList = ({ items, tone, emptyLabel = "empty" }: QueueListProps) => { + if (items.length === 0) { + return ( +

    + {emptyLabel} +

    + ); + } + + return ( +
    + {items.map((item, index) => ( +
    + {item} +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/async-await/components/TimelineBars.tsx b/src/components/visualizations/async-await/components/TimelineBars.tsx new file mode 100644 index 0000000..e38375d --- /dev/null +++ b/src/components/visualizations/async-await/components/TimelineBars.tsx @@ -0,0 +1,60 @@ +import { cn } from "@/lib/utils"; +import { TIMELINE_FILL, TIMELINE_TOTAL_MS } from "../helpers"; +import type { TimelineBar } from "../types"; + +interface TimelineBarsProps { + bars: TimelineBar[]; + elapsedMs: number; +} + +export const TimelineBars = ({ bars, elapsedMs }: TimelineBarsProps) => { + if (bars.length === 0) { + return ( +

    + nothing scheduled +

    + ); + } + + const elapsedPct = Math.min(100, (elapsedMs / TIMELINE_TOTAL_MS) * 100); + + return ( +
    +
    + 0 ms + now: {elapsedMs} ms + {TIMELINE_TOTAL_MS} ms +
    +
      + {bars.map((bar) => { + const left = (bar.startMs / TIMELINE_TOTAL_MS) * 100; + const width = ((bar.endMs - bar.startMs) / TIMELINE_TOTAL_MS) * 100; + return ( +
    • + + {bar.label} + + + + + +
    • + ); + })} +
    +
    + ); +}; diff --git a/src/components/visualizations/async-await/components/TimerList.tsx b/src/components/visualizations/async-await/components/TimerList.tsx new file mode 100644 index 0000000..3b65a12 --- /dev/null +++ b/src/components/visualizations/async-await/components/TimerList.tsx @@ -0,0 +1,33 @@ +import { cn } from "@/lib/utils"; +import type { TimerItem } from "../types"; + +export const TimerList = ({ timers }: { timers: TimerItem[] }) => { + if (timers.length === 0) { + return ( +

    + no timers +

    + ); + } + + return ( +
    + {timers.map((timer) => ( +
    + {timer.label} + + {timer.status === "due" ? "due" : `${timer.remainingMs} ms left`} + +
    + ))} +
    + ); +}; diff --git a/src/components/visualizations/async-await/data.ts b/src/components/visualizations/async-await/data.ts index cb0ff5c..dde0e65 100644 --- a/src/components/visualizations/async-await/data.ts +++ b/src/components/visualizations/async-await/data.ts @@ -1 +1,722 @@ -export {}; +import type { AsyncAwaitExample, AsyncFnState, TimelineBar, TimerItem } from "./types"; + +const SCRIPT = "