| name | drn-buildwww-react |
| description | DRN buildwww React 19 mounted islands - Shadow DOM, Tailwind CSS 4, typed registry, DRN.React mount/update/dispose API, Razor integration, and Vite IIFE build. Keywords: drn, buildwww, react, islands-architecture, onmount, components, shadow-dom, typescript, tailwind, mount-api, iife |
| last-updated | "2026-06-23T00:00:00.000Z" |
| difficulty | advanced |
| tokens | ~4K |
DRN React Mounted Islands (buildwww)
React 19 components rendered client-side into isolated Shadow DOM islands within server-rendered Razor Pages — no SPA, no hydration.
DRN-scoped: use only when the repository profile or filesystem declares DRN buildwww and the DRN browser API surface.
When to Apply
- Creating new React components for Razor Pages
- Modifying
reactBundle.tsx or the component registry
- Adding island→host communication (callbacks, props updates)
- Debugging Shadow DOM style isolation or lifecycle issues
- Configuring Vite React build (
build:react)
Table of Contents
- Architecture Overview
- File Layout
- Type System
- Vite Build Configuration
- CSS Strategy
- The Component Registry
- Mount API
- Razor Page Integration
- Adding a New Component
- Conventions
- Related Skills
Architecture Overview
This convention uses Mounted Islands Architecture — React components are explicitly mounted into target DOM nodes as isolated islands, not a monolithic SPA. Each island:
- Renders client-side only (no SSR/hydration — Razor Pages handle server rendering)
- Mounts inside Shadow DOM by default for CSS isolation from Bootstrap
- Uses Tailwind CSS 4 inside Shadow DOM while the host page uses Bootstrap 5
- Integrates with the repository's onmount wrapper for lifecycle management (mount on connect, dispose on HTMX swap)
- Communicates with the host page via typed props and callbacks
Script Loading Prerequisites
reactBundle.tsx relies on the page-wide script loading order defined in drn-buildwww-libraries. Key constraint: appPreload.js must initialize the shared window.DRN.React object before this bundle loads — otherwise mount() is not attached and a critical error is logged.
Treat DRN, Drn*, and DRN.Framework.* names as shared framework API, not placeholders for app-specific namespaces.
File Layout
<frontend-package>/buildwww/
├── lib/react/
│ ├── reactBundle.tsx # Entry point — registry, mount API, Shadow DOM, ErrorBoundary
│ ├── reactBundle.css # CSS aggregator — @reference tailwind, @import components
│ └── components/ # One file pair per component (add more here)
│ ├── HelloReactComponent.tsx # Reference implementation
│ └── HelloReactComponent.css # Scoped styles (.drn-react-root prefix)
├── types/
│ └── DrnReactTypes.ts # Shared types — registry, RootData, ReactMountedIsland
└── app/js/drn/
└── drnOnmount.js # Mount/unmount lifecycle manager
HelloReactComponent is the reference implementation. New components follow the same file-pair pattern inside components/ and are wired via the 4-point registration described in Adding a New Component.
Checked-in output: wwwroot/lib/react/reactBundle.[hash].js. Use the manifest as the source of truth before adding Razor link tags for React assets.
Type System
All React-specific types live in buildwww/types/DrnReactTypes.ts:
export type ReactComponentRegistry = {
'HelloReact': React.ComponentType<HelloReactProps>;
};
export type RootData<P = unknown> = {
root: Root;
name: string;
isShadow: boolean;
currentProps?: P;
};
export interface Disposable {
dispose: () => void;
}
export interface ReactMountedIsland<P> extends Disposable {
update: (newProps: Partial<P>) => void;
getProps: () => P | null;
}
export interface ReactMountOptions {
useShadow?: boolean;
}
Vite Build Configuration
React has its own dedicated build invoked via npm run build:react:
react: {
plugins: [
react(),
tailwindcss()
],
build: {
outDir: 'wwwroot/lib/react',
rolldownOptions: {
input: {
reactBundle: resolve(__dirname, 'buildwww/lib/react/reactBundle.tsx'),
},
output: {
format: 'iife',
name: 'DrnReactMicroFrontend'
},
transform: {
define: { 'import.meta': '{}' }
}
}
}
}
Key decisions:
- IIFE format — self-executing bundle, no ES module dependency resolution at runtime
- Separate build — React bundle is independent from the app/bootstrap/htmx builds
- Tailwind plugin — compiles
@apply directives at build time; no runtime Tailwind in browser
CSS Strategy
Dual-Import Pattern
reactBundle.tsx imports CSS twice for two different purposes:
import './reactBundle.css'
import bundleStyles from './reactBundle.css?inline';
- First import: lets Vite/Tailwind process the CSS dependency for the bundle
- Second import (
?inline): raw CSS string used to create constructable stylesheets for Shadow DOM
CSS Aggregation
reactBundle.css aggregates all component styles — add one @import per component:
@reference "tailwindcss/theme";
@reference "tailwindcss/utilities";
@import "./components/HelloReactComponent.css";
@reference loads Tailwind tokens without emitting :root custom properties — prevents leaking into the host page.
Scoping Convention
All component CSS is scoped under .drn-react-root:
.drn-react-root .hello-react { ... }
.drn-react-root .hello-react-card { ... }
.drn-react-root is applied to:
- Shadow DOM: the portal host
<div> inside the shadow root
- Light DOM (
useShadow: false): added as a class to the mount element
Shadow DOM Style Injection
const sharedSheet = new CSSStyleSheet();
sharedSheet.replaceSync(bundleStyles);
shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sharedSheet];
const styleTag = document.createElement('style');
styleTag.textContent = bundleStyles;
shadow.appendChild(styleTag);
CSS↔JS Coordination (Timing Tokens)
CSS custom properties serve as the single source of truth for animation timing. HelloReactComponent demonstrates this pattern:
.drn-react-root .hello-react {
--badge-fade-ms: 400;
--title-cycle-ms: 4000;
}
JavaScript reads these via getComputedStyle:
const styles = getComputedStyle(helloRoot);
const FADE_DURATION_MS = parseInt(styles.getPropertyValue('--badge-fade-ms') || '400', 10);
This pattern is optional per component — use it when CSS should own animation timing constants.
The Component Registry
All mountable components are registered in reactBundle.tsx. Each component gets one entry — the registry grows as you add components:
const COMPONENT_REGISTRY: ReactComponentRegistry = {
'HelloReact': HelloReactComponent
};
The registry maps string keys to component types. DRN.React.mount('HelloReact', ...) looks up this registry. An unregistered name logs an error listing available components.
Root Tracking
A WeakMap<HTMLElement, RootData> (rootMap) tracks mounted roots per DOM element:
- Prevents double-mounting
- Enables safe re-mount (cleans up existing root if component name or shadow mode changes)
- WeakMap ensures garbage collection when elements are removed from DOM
Mount API
DRN.React.mount(name, element, props, options?)
Returns ReactMountedIsland<P> | null:
const island = DRN.React.mount('HelloReact', domElement, {
title: 'Hello',
onReady: () => console.log('Ready'),
onCardClick: () => console.log('Clicked')
});
Return value API:
| Method | Purpose |
|---|
island.update(partialProps) | Merge new props and re-render without remounting |
island.getProps() | Returns shallow copy of current merged props, or null after dispose |
island.dispose() | Unmounts React root and removes from tracking map |
Rendering Pipeline
Every mounted component is wrapped in:
<React.StrictMode>
<IslandErrorBoundary>
{React.createElement(Component, props)}
</IslandErrorBoundary>
</React.StrictMode>
IslandErrorBoundary catches render failures per-island — one crashed island does not affect other islands or the host page.
Razor Page Integration
Islands mount via the onmount wrapper — not via declarative data-app-island attributes.
Pattern
<div data-js-my-component-island></div>
@section Scripts {
<script>
DRN.Onmount.registerFull('[data-js-my-component-island]', function (options) {
const island = DRN.React.mount('MyComponent', this, {
title: 'Hello',
onReady: () => console.log('Component ready')
});
options.disposable = island;
}, function (options) {
if (this._myIntervalId)
clearInterval(this._myIntervalId);
DRN.Onmount.unregister.call(this, options);
});
</script>
}
Key Integration Points
this — the DOM element matched by the selector
options.disposable = island — wires island disposal into the onmount unregister lifecycle
- Custom unregister — clean up non-React resources first (intervals, DOM listeners), then delegate to default
unregister
- Shadow DOM access —
this.shadowRoot gives access to the shadow root for querying rendered content
- Props from server — use
@Json.Serialize() to pass server-side data as initial props
Server-Side Version Injection
var pkg = PackageVersions.Instance;
versions: {
dotnet: @Json.Serialize(pkg.Dotnet),
react: @Json.Serialize(pkg.React)
}
Adding a New Component
1. Create component file
buildwww/lib/react/components/MyComponent.tsx
buildwww/lib/react/components/MyComponent.css (optional)
2. Export props interface and component
export interface MyComponentProps {
title: string;
onReady?: () => void;
}
export const MyComponent = ({ title, onReady }: MyComponentProps) => {
useEffect(() => { onReady?.(); }, [onReady]);
return <div className="my-component">{title}</div>;
};
3. Register in type system
Update buildwww/types/DrnReactTypes.ts:
export type ReactComponentRegistry = {
'HelloReact': React.ComponentType<HelloReactProps>;
'MyComponent': React.ComponentType<MyComponentProps>;
};
4. Register in bundle
Update reactBundle.tsx:
import { MyComponent } from './components/MyComponent';
const COMPONENT_REGISTRY: ReactComponentRegistry = {
'HelloReact': HelloReactComponent,
'MyComponent': MyComponent
};
5. Add CSS import (if applicable)
Update reactBundle.css:
@import "./components/MyComponent.css";
6. Mount from Razor
Follow the Razor Page Integration pattern.
7. Build
npm run build:react
Conventions
Callback Convention
Components accept on*-prefixed function props for island→host event notification:
| Rule | Example |
|---|
Name with on prefix | onReady, onSelectionChange, onSubmit |
| Execute in host page scope | Outside Shadow DOM — vanilla JS context |
Replaceable via update() | island.update({ onReady: newHandler }) |
Removable via update() | island.update({ onReady: undefined }) |
| Pass plain data only | No React internals in callback arguments |
CSS Conventions
- All selectors scoped under
.drn-react-root .component-name
- Use
@apply from Tailwind utilities — compiles at build time
- Timing tokens as CSS custom properties (CSS is source of truth)
- No global
:root styles — use @reference not @import for Tailwind
Lifecycle Conventions
- Always set
options.disposable = island in registerFull
- Custom cleanup (intervals, DOM listeners) in unregister callback before
DRN.Onmount.unregister.call()
- Never call
root.unmount() directly — use island.dispose() instead
Evolution Triggers (from HomeAnonymous.cshtml roadmap)
| Trigger | Action |
|---|
| 5–6+ components with shared state | Add lightweight cross-island state bus |
| Bundle size concern | Lazy-load registry entries with dynamic import() |
| High-performance isolated islands | Consider Svelte 5 or Vue Vapor mode registries |
| Multiple frameworks coexist | Invest in full micro-frontend separation |
Related Skills