Skip to content

feat(examples): add micro-frontend example with Hub + multi-framework sub-apps#296

Open
lzxb wants to merge 91 commits intomasterfrom
feat-micro-app-example
Open

feat(examples): add micro-frontend example with Hub + multi-framework sub-apps#296
lzxb wants to merge 91 commits intomasterfrom
feat-micro-app-example

Conversation

@lzxb
Copy link
Copy Markdown
Contributor

@lzxb lzxb commented May 7, 2026

Summary

BREAKING CHANGE: Redesign the router mounting API (rootappId) and add
first-class SSR hydration support. Replace all legacy examples with a unified
micro-frontend architecture demo.

Core API Changes

root replaced by appId

  • RouterOptions.root (string | HTMLElement) → appId (string)
  • Client: resolves via document.getElementById(appId)
  • Server: wraps renderToString() output in <div id="${appId}">
  • Default: 'app'

SSR hydration support

  • RouterMicroAppOptions adds hydration(el) callback
  • Server renders <div id="${appId}" data-ssr>...</div>
  • Client detects data-ssr, calls hydration() instead of mount(), then removes the attribute
  • Prevents DOM flicker and preserves interactive state

resolveLink hook

  • New RouterOptions.resolveLink option for custom router-link transformation

Dev-only SSR validation

  • renderToString() output is validated to contain exactly one root element
  • Catches framework-level SSR mismatches early

Example Projects

Removed 15+ legacy demos (router-demo-*, ssr-demo-*, ssr-vue2-host/remote).

Added examples/micro-app/ micro-frontend suite:

Package Framework
ssr-micro-hub Hub dispatcher with shared sidebar layout
ssr-micro-html Vanilla HTML + TypeScript
ssr-micro-vue2 Vue 2.7 + Composition API
ssr-micro-vue3 Vue 3.5 + SSR
ssr-micro-react React 18 + Hooks
ssr-micro-shared Shared Layout, BaseApp, SSR styles

Notable Fixes

  • Vue2: fix v-html hydration mismatch caused by inline style whitespace normalization
  • Vue2: remove wrapper div from renderToString so data-server-rendered attaches correctly
  • All frameworks: unify mount/unmount DOM lifecycle to prevent residue nodes during app switching

… sub-apps

- Add ssr-micro-shared for sharing @esmx/router across all sub-apps
- Add ssr-micro-html with native HTML + TypeScript
- Add ssr-micro-vue2 with Vue 2.7 + Composition API
- Add ssr-micro-vue3 with Vue 3.5 + SSR
- Add ssr-micro-react with React 18 + Hooks
- Add ssr-micro-hub to aggregate all sub-app routes
- Configure modules.exports for framework library sharing (vue, react, react-dom)
- Configure server-only exports for vue-server-renderer and @vue/server-renderer
- Add README.md and README.en.md for each package
- Remove outdated ssr-demo-html and ssr-demo-preact-htm examples
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 7, 2026

Deploying esmx with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9b8f72d
Status:🚫  Build failed.

View logs

@lzxb lzxb force-pushed the feat-micro-app-example branch from 37060e3 to 0070dfb Compare May 7, 2026 12:44
Dev added 24 commits May 7, 2026 20:53
… cards

- Add consistent styling system with CSS animations
- Redesign home page with modern cards, icons, and feature section
- Update all sub-apps with consistent card-based layout
- Add fade-in animations for page transitions
- Use container element pattern for clean unmounting
- Improve visual hierarchy and responsive design
…igation

- Create useLayout() composable in ssr-micro-shared
- Add consistent sidebar with navigation across all micro-apps
- Integrate layout into Vue2, Vue3, React, HTML, and Hub home page
- Remove global layout styles from entry.server.ts
- Use fixed DOM IDs (esmx-sidebar, esmx-layout-footer) across all apps
- Only update active state and rebind events when DOM already exists
- Avoid DOM recreation during micro-app transitions
- Layout is now a class instance with mount/unmount methods
- useLayout() acts as a composable factory for Vue2/Vue3 setup usage
- Maintains shared DOM behavior to prevent flicker
…removing global animations

- Remove global fadeIn animation from entry.server.ts
- Return empty string in Layout.header if sidebar DOM already exists
- Prevent v-html from replacing existing sidebar on each app switch
- Vue3: Move app creation to factory function, use v-once in template
- React: Simplify app structure, add biome-ignore for safe HTML
- HTML: Move Layout creation to mount function
- Hub: Simplify home.ts Layout usage
…HomeApp)

- HtmlApp: class-based HTML micro-app with render/mount/unmount methods
- HomeApp: class-based Hub home page with render/mount/unmount methods
- Both follow Vue pattern: instance created in factory function
- Move SSR-specific deps (ssr-micro-shared, @esmx/router) to devDependencies
- Fix React app: useMemo for Layout, proper hook dependencies
- Fix Vue2 entry comments to match other sub-apps
- Remove unused private member in HtmlApp
- Add biome override for dangerouslySetInnerHTML in micro-app
Move vue-server-renderer and @vue/server-renderer imports into
renderToString() to prevent client-side module resolution errors.
These modules are server-only (client: false) and should not be
statically imported in client bundles.
…hrefs

router.resolveLink() generates absolute URLs based on the router's base URL.
During static build (postBuild), the base URL is hardcoded to localhost,
which causes all sidebar links to point to http://localhost:3000/... in
production deployments. This breaks SPA navigation and causes unexpected
jumps to localhost.

Fix: Use relative paths (e.g., /html) directly for href attributes.
The actual SPA navigation is handled by event delegation calling
router.push(), which correctly resolves paths against the current base.
Add RouterOptions.resolveLink option to allow transforming the result
of resolveLink() before it's returned. This enables use cases like
removing the domain from href attributes for static site generation.

Usage:
const router = new Router({
    base: new URL(base),
    resolveLink(link) {
        link.attributes.href = link.route.url.href
            .slice(link.route.url.origin.length) || '/';
        return link;
    }
});

Also update micro-app example to use resolveLink option instead of
hardcoding relative paths in hrefs.
Cloudflare Pages provides CF_PAGES_URL env var during build.
Use it to generate correct base URL with /ssr-micro-hub/ path prefix
so that resolveLink generates correct relative hrefs like
/ssr-micro-hub/html instead of /html.
@lzxb lzxb force-pushed the feat-micro-app-example branch from 94bc7e4 to d8f5f50 Compare May 8, 2026 08:27
Dev and others added 12 commits May 9, 2026 17:58
vue-server-renderer collects component CSS via context.renderStyles,
but the context was never passed. Use a WeakMap utility (setSsrStyles/
getSsrStyles) keyed by Router instance to pass collected CSS from the
Vue2 micro-app to the hub's entry.server, injecting it into <head>.
Vue2's $mount(el, true) checks data-server-rendered on el itself.
The wrapper div pushed the attribute one level deeper to
el.firstElementChild, causing Vue2 to skip hydration and do a
full re-render, which caused page style flickering.

Vue3 and React hydration starts from container.firstChild, so the
wrapper is still needed for them.
Vue2 hydration does a strict byte-for-byte comparison of v-html
domProps.innerHTML vs the browser-normalized elm.innerHTML (line
6930 of vue.runtime.common.dev.js). Multiline style attribute
values from template literals survive in SSR but get collapsed by
the browser, causing the comparison to fail and hydration to bail.

Add normalizeHtml() helper that compresses whitespace inside style
attribute values so the generated HTML matches browser output.
…ue2 app

Extract the sidebar into a header.vue component using Vue2 scoped
styles and useLink() for navigation. Rewrite app.vue content area
with scoped CSS. This eliminates the v-html hydration byte-for-byte
comparison problem entirely and provides proper CSS scoping via
data-v-* attributes.
Vue2's custom header component was the wrong direction. All four
micro-apps should use the shared Layout for header/footer with
the normalizeHtml fix already in layout.ts. Revert Vue2 app.vue
and Vue3/React back to using Layout + v-html/dangerouslySetInnerHTML.
- Remove unused ssr/getCurrentInstance/useSSRContext variables from Vue2/Vue3 app.vue
- Remove unnecessary nextTick wrappers around layout.mount()
- Hoist React App component (was defined 3x identically in create-app.tsx)
- Deduplicate Vue3 onMount/onHydration (both call this.app.mount)
- Extract SIDEBAR_WIDTH constant (260px) to shared layout.ts
- Remove unnecessary Promise.resolve() wrapper in react/create-app.tsx
Add inline onclick handler to anchor tags to preventDefault()
before the browser processes the href on mobile touch events.
Without this, mobile browsers navigate via href before the
delegated click event fires, causing full page refreshes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace direct router.context.head assignments with setRouterHead/getRouterHead
utilities in ssr-micro-shared. Uses WeakMap<Router, Unhead> for storage,
consistent with the existing ssr-styles pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add responsive mobile sidebar with hamburger menu and overlay
- Fix SPA navigation by adding onclick preventDefault to nav links
- Ensure cross-app switching is seamless without full page reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace .filter((value) => value.file) with if (value.file) inside
the map callback. The filter was incorrectly excluding pkg-type exports
(like vue-server-renderer) which have file: undefined, causing them
to not be externalized and triggering SWC minifier errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…new app removal

When switching micro-apps, Vue/React unmount() removes its own DOM.
The subsequent firstElementChild?.remove() then targets the NEW app
instead of the old one, causing the newly mounted app to disappear.

Fix by capturing the old element reference before calling unmount().
Non-hydration branch was creating intermediate div causing old+new
app DOM to coexist during route switches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@lzxb lzxb force-pushed the feat-micro-app-example branch from 25475b3 to 1d19f7d Compare May 10, 2026 03:39
Dev and others added 6 commits May 10, 2026 11:52
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add nav item hover effect (rgba blue background)
- Add focus-visible ring for hamburger and close buttons
- Add mobile sidebar close button (×) with event handler
- Mobile sidebar width uses min(260px, 80vw)
- Reduce mobile content padding to 16px via #esmx-main
- Unify nav icons: 🏠 → Hm (all text-based)
- Add card hover effect (shadow + border color) on home page
- Unify badge size to 56×56px / 14px radius across all apps
- Add id=esmx-main to main content areas for CSS targeting
…y, type scale

- Localize logo as inline SVG data URI (layout + home page)
- Add favicon as inline SVG data URI via Layout header
- Reduce desktop sidebar brand font from 1.5rem to 1.25rem
- Fix type scale: tag badge 12px → 0.75rem
- Add role=img + aria-label to all badge elements for accessibility
- Sidebar nav: Esmx/HTML5/Vue/React SVG icons
- Detail page badges: framework SVGs on gradient backgrounds
- Home page cards: framework SVG icons
- SVG logos encoded as inline data URIs or markup strings
…tokens

- Define CSS custom properties for all colors (bg, text, border, etc.)
- Add @media (prefers-color-scheme: dark) overrides
- Add transition animations to nav items and cards
- Add card hover scale effect (transform: scale(1.01))
- Add subtle radial gradient to main content background
- Replace all hardcoded colors with var(--esmx-*) tokens
- Update hub entry.server.ts body styles to use CSS variables
@lzxb lzxb force-pushed the feat-micro-app-example branch from c2dfcec to ceae750 Compare May 10, 2026 07:02
toggleSidebar now uses direct style manipulation as fallback for
inline style conflicts. Mobile open state CSS uses !important to
ensure transform and width overrides win over inline styles.
@lzxb lzxb force-pushed the feat-micro-app-example branch from ceae750 to 8cbc5c2 Compare May 10, 2026 07:07
Dev added 8 commits May 10, 2026 15:21
…flicts

When switching micro-apps, the new app's Layout.mount() runs before
the old app's DOM is removed. With hardcoded global IDs (esmx-menu-btn,
esmx-sidebar, etc.), document.getElementById finds the OLD element,
binding events that get destroyed moments later.

Prefix all Layout element IDs with this.appId so each instance has
unique DOM targets, independent of mount order.
Layout now assigns the main content id dynamically in mount() by
finding headerEl.nextElementSibling. App files no longer need to
set any esmx-main id — the Layout manages it entirely.

All sidebar/menu/overlay IDs now use this.appId prefix to avoid
conflicts when switching micro-apps.
- Remove headerId/footerId — Layout no longer exposes IDs externally
- All DOM queries use this.appId-scoped IDs (sidebar, menu-btn, etc.)
- Mount/unmount only touch elements created by Layout itself
- Remove CSS rules targeting external elements (#s-main, body)
- App files no longer reference any Layout IDs
… API

Layout now scopes all internal DOM IDs (sidebar, menu-btn, overlay,
close-btn) with this.appId prefix to avoid conflicts when switching
micro-apps.

Public API (headerId, footerId) preserved — these are the contract
between Layout and framework for positioning header/footer content.

mount() finds sidebar via scoped ID instead of headerId container,
making it resilient to DOM ordering during app transitions.
- vue: 3.5.23 → 3.5.34
- @vue/server-renderer: 3.5.23 → 3.5.34
- unhead: ^2.1.0 → ^3.1.0
- @unhead/vue: ^2.1.0 → ^3.1.0
- @unhead/react: ^2.1.15 (kept, 3.x requires React ≥19)
- vue-tsc: ^3.0.5 → ^3.2.8
- tsc-alias: ^1.8.16 → ^1.8.17
- @types/node: ^24.0.0 → ^24.12.3
- typescript: 5.8.3 → 6.0.3
- vue: ^3.5.23 → ^3.5.34
- @vue/server-renderer: ^3.5.23 → ^3.5.34
- react/react-dom/@types: ^18.0.0 → ^18.3.1
- react/react-dom: ^18.3.1 → ^19.0.6
- @types/react: ^18.3.1 → ^19.2.14
- @types/react-dom: ^18.3.1 → ^19.2.3
- @unhead/react kept at 2.1.15 (3.x needs react>=19.2.4 not yet stable)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant