Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
/.astro
/dist
/public/assets/code-viewer
/public/ig-themes

# browser auto-generated files:
/browser/node_modules
Expand Down
83 changes: 60 additions & 23 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,44 @@ function stripSampleInstantiation() {
}

/**
* Vite plugin: convert local CSS/SCSS imports in sample index.ts files to
* `?inline` imports with dynamic `<style>` injection.
* Vite plugin: take the stylesheet imports out of every sample entry module.
*
* WHY this is needed
* ──────────────────
* Rollup picks certain sample chunks as "hosts" for shared IgniteUI code.
* Any CSS imported by those host chunks ends up in a SHA-named CSS file that
* Vite's `__vite__mapDeps` then preloads on EVERY page that transits through
* that shared chunk — causing unrelated sample styles to appear on the wrong
* pages and override each other.
* A sample's entry module is loaded lazily, by slug, from a dynamic import that
* only runs after DOMContentLoaded — and it drags in megabytes of library code.
* While that is in flight the page has already painted, so any CSS the module
* owns arrives far too late: the sample flashes unstyled first.
*
* By converting import './index.css' → import __css from './index.css?inline'
* we keep the CSS as a plain string inside the owning JS module. Vite no
* longer emits a separate CSS chunk for it, so nothing leaks into other pages.
* The `<style>` is only injected when that specific sample's JS actually runs.
* It was also a correctness problem. Rollup hoists shared code into some
* chunk, and evaluating a chunk runs the whole module body — so a *different*
* sample's theme import could land in the document first and win the library's
* one-shot `getTheme()` check, rendering material samples with the bootstrap
* theme.
*
* So `[...slug].astro` now emits these stylesheets into <head> at build time
* (see resolveSampleStyles): themes as <link>s to public/ig-themes/, everything
* sample-local inlined. This plugin drops the imports it has taken over, so the
* two can't both own the same sheet — otherwise the module's copy would
* re-append itself last on every load and clobber a theme swap.
*
* Anything whose shape the page does NOT resolve is deliberately left alone and
* still injected at runtime, so an unrecognised import degrades to the old
* behaviour instead of silently losing its styles.
*/
/** @returns {import('vite').Plugin} */
function inlineSampleCss() {
// Matches any CSS / SCSS side-effect import inside a sample file (relative or package).
const localCssRe = /^import\s+['"]([^'"]+\.(?:css|scss))['"];?\s*$/gm;
const cssImportRe = /^import\s+['"]([^'"]+\.(?:css|scss))['"];?\s*$/gm;

// The two shapes [...slug].astro knows how to put in <head>. Keep in sync
// with resolveSampleStyles() in src/utils/samples.ts.
const themeSpecRe =
/^igniteui-webcomponents(-grids\/grids)?\/themes\/(light|dark)\/(material|bootstrap|fluent|indigo)\.css$/;
const sampleLocalRe = /^\.\/[^/]+\.(?:css|scss)$/;

const handledInHead = spec => themeSpecRe.test(spec) || sampleLocalRe.test(spec);

let isBuild = false;

return {
Expand All @@ -69,22 +87,25 @@ function inlineSampleCss() {
isBuild = config.command === 'build';
},
transform(code, id) {
// Production build only — in dev, Vite handles CSS imports natively
// (injects as <style> tags automatically) which works correctly per-module.
if (!isBuild) return;
if (!id.replace(/\\/g, '/').match(/\/samples\/.+\/src\/index\.ts$/)) return;
localCssRe.lastIndex = 0;
if (!localCssRe.test(code)) return;
localCssRe.lastIndex = 0;
cssImportRe.lastIndex = 0;
if (!cssImportRe.test(code)) return;
cssImportRe.lastIndex = 0;

let i = 0;
const newCode = code.replace(localCssRe, (_, cssPath) => {
const newCode = code.replace(cssImportRe, (line, spec) => {
// Already in <head> — drop it so nothing is styled twice.
if (handledInHead(spec)) return '';

// In dev Vite injects CSS imports natively, which is correct per-module.
if (!isBuild) return line;

// Production fallback for shapes the page could not resolve. ?inline
// keeps the CSS as a string inside this module, so Vite emits no shared
// CSS chunk that could leak onto unrelated pages.
const v = `__sampleCss${i++}`;
// ?inline → Vite compiles SCSS→CSS and returns the result as a string.
// We inject it via a <style> element so it only appears in the DOM
// when this exact sample's JS module is imported — never on other pages.
return [
`import ${v} from '${cssPath}?inline';`,
`import ${v} from '${spec}?inline';`,
`{const __s=document.createElement('style');__s.textContent=${v};document.head.appendChild(__s);}`,
].join('\n');
});
Expand Down Expand Up @@ -233,11 +254,27 @@ export default defineConfig({
output: {
// Give every sample its own chunk so Rollup doesn't try to inline
// all 700+ samples into a single bundle (causes OOM).
//
// Library code goes into vendor/<package> chunks. Without this,
// Rollup picks an arbitrary *sample* chunk to host the shared
// IgniteUI code, so loading any sample also evaluates that host
// sample's module body — running its `defineAllComponents()` and
// injecting its theme CSS on a page it has nothing to do with.
// That is what made the material-themed samples (button-group)
// render with the bootstrap theme: the host chunk injected
// bootstrap.css and defined the elements first, so the library's
// one-shot `getTheme()` latched onto `--ig-theme: bootstrap`
// before the sample's own material.css was ever added.
manualChunks(id) {
const match = id.match(/[\\/]samples[\\/](.+)[\\/]src[\\/]index\.ts$/);
if (match) {
return `samples/${match[1].replace(/[\\/]/g, '--')}`;
}

const dep = id.replace(/\\/g, '/').match(/\/node_modules\/((?:@[^/]+\/)?[^/]+)\//);
if (dep) {
return `vendor/${dep[1].replace('/', '--')}`;
}
},
// Keep sample CSS files scoped to their own chunk names
assetFileNames(assetInfo) {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
"type": "module",
"scripts": {
"add:sample": "node scripts/add-sample.js",
"predev": "node scripts/copy-ig-themes.js",
"dev": "astro dev --port 4200",
"prebuild": "node scripts/generate-code-viewer.js",
"prebuild": "node scripts/copy-ig-themes.js && node scripts/generate-code-viewer.js",
"build": "cross-env NODE_OPTIONS=--max_old_space_size=8192 astro build",
"preview": "astro preview --port 4200",
"build:preview": "npm run build && npm run preview",
"generate:code-viewer": "node scripts/generate-code-viewer.js",
"update:ig": "node scripts/update-ig.js",
"check": "astro check"
Expand Down
20 changes: 20 additions & 0 deletions public/styles/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,23 @@ main {
#nav-bar::-webkit-scrollbar { width: 6px; }
#nav-bar::-webkit-scrollbar-track { background: #2c2c2c; }
#nav-bar::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }

/*
* Hide not-yet-upgraded custom elements until the sample module has run.
*
* A sample's stylesheets are now in <head>, so it is styled at first paint —
* but its custom elements are only defined by the lazily-loaded sample chunk.
* Until then <igc-button-group> and friends are unknown *inline* elements, so
* the browser lays their slotted text out as a run of raw inline words before
* snapping into place.
*
* The guard is bounded by TIME, not by element: [...slug].astro sets
* data-sample-ready on <html> once the sample module has finished (success or
* failure), and the whole rule switches off. It must not be left to
* :not(:defined) alone — that also matches hyphenated tags a library renders
* internally and never registers, and igc-grid does exactly that, so an
* element-bounded guard hides the grid's own content forever.
*/
html:not([data-sample-ready]) #router-target :not(:defined) {
visibility: hidden;
}
72 changes: 72 additions & 0 deletions scripts/copy-ig-themes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* copy-ig-themes.js
*
* Copies the Ignite UI theme stylesheets out of node_modules into
* public/ig-themes/ so the sample pages can reference them with a real
* <link rel="stylesheet"> in <head>.
*
* WHY this is needed
* ──────────────────
* Sample entry modules used to `import 'igniteui-webcomponents/themes/...css'`,
* which the inline-sample-css plugin turned into a runtime `<style>` injection.
* That meant no theme existed until megabytes of sample JS had downloaded and
* evaluated — the page painted unstyled first (FOUC), and the library's
* one-shot `getTheme()` could latch onto the wrong theme before the right
* sheet ever landed.
*
* Serving them as static files instead gives us a stylesheet that:
* • blocks first paint, so the sample never renders unthemed,
* • is cached once and reused by all ~980 pages,
* • can be re-pointed by changing one `href` (see sample-theme handling).
*
* Output layout:
* public/ig-themes/webcomponents/{light,dark}/{material,bootstrap,fluent,indigo}.css
* public/ig-themes/grids/{light,dark}/{material,bootstrap,fluent,indigo}.css
*/
import { existsSync, mkdirSync, readdirSync, copyFileSync, rmSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

/** Unscoped package if installed, otherwise the @infragistics/ scoped build. */
function pkgDir(pkg) {
const unscoped = path.join(root, 'node_modules', pkg);
if (existsSync(unscoped)) return unscoped;
const scoped = path.join(root, 'node_modules', '@infragistics', pkg);
return existsSync(scoped) ? scoped : null;
}

// key → directory inside the package that holds {light,dark}/*.css
const SOURCES = {
webcomponents: ['igniteui-webcomponents', 'themes'],
grids: ['igniteui-webcomponents-grids', 'grids/themes'],
};

const outRoot = path.join(root, 'public', 'ig-themes');
rmSync(outRoot, { recursive: true, force: true });

let copied = 0;
for (const [key, [pkg, subdir]] of Object.entries(SOURCES)) {
const dir = pkgDir(pkg);
if (!dir) {
console.warn(`[copy-ig-themes] ${pkg} not installed — skipping`);
continue;
}

for (const variant of ['light', 'dark']) {
const from = path.join(dir, ...subdir.split('/'), variant);
if (!existsSync(from)) continue;

const to = path.join(outRoot, key, variant);
mkdirSync(to, { recursive: true });

for (const file of readdirSync(from)) {
if (!file.endsWith('.css')) continue;
copyFileSync(path.join(from, file), path.join(to, file));
copied++;
}
}
}

console.log(`[copy-ig-themes] copied ${copied} stylesheet(s) to public/ig-themes/`);
33 changes: 32 additions & 1 deletion src/layouts/SampleLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import NavSidebar from '../components/NavSidebar.astro';

const base = import.meta.env.BASE_URL.replace(/\/$/, '');

/** A stylesheet the sample needs, already resolved by the page. */
type SampleStyle =
| { kind: 'link'; href: string; pkg: 'webcomponents' | 'grids'; variant: string; theme: string }
| { kind: 'inline'; css: string };

interface Props {
slug?: string;
title?: string;
styles?: SampleStyle[];
}

const { slug = '', title = 'Ignite UI | Web Components' } = Astro.props;
const { slug = '', title = 'Ignite UI | Web Components', styles = [] } = Astro.props;

// True only for the index page (no slug). Used to set the initial nav state.
const isIndex = !slug;
Expand All @@ -31,6 +37,31 @@ const isIndex = !slug;
<!-- Infragistics shared sample stylesheet (shared across all WC samples) -->
<link rel="stylesheet" href="https://dl.infragistics.com/x/css/samples/shared.v8.css" />
<link rel="stylesheet" href={`${base}/styles/layout.css`} />

<!--
The sample's own stylesheets, emitted in the exact order its entry module
imports them (that order is the cascade order).

These used to be injected at runtime by the sample's JS chunk, which meant
the page painted unstyled until megabytes of JS had downloaded — and let
the library's one-shot theme detection latch onto whatever sheet happened
to land first. Emitting them here makes the sample styled at first paint.

Theme sheets carry data-ig-theme so they can be re-pointed in place (see
the docs theming widget bridge); swapping one is only half a theme change,
though — the library also needs configureTheme() to re-adopt its shadow
styles, since getTheme() is memoised on first component connect.
-->
{styles.map(style => style.kind === 'link'
? <link
rel="stylesheet"
href={style.href}
data-ig-theme={style.pkg}
data-ig-theme-variant={style.variant}
data-ig-theme-name={style.theme}
/>
: <style set:html={style.css}></style>
)}
</head>

<body>
Expand Down
52 changes: 48 additions & 4 deletions src/pages/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import SampleLayout from '../layouts/SampleLayout.astro';
import { keyToSlug, slugToInfo, extractSampleHtml } from '../utils/samples';
import { keyToSlug, slugToInfo, extractSampleHtml, resolveSampleStyles } from '../utils/samples';

// All sample index.html files (raw HTML for server-side rendering)
// Path is relative to THIS file: src/pages/[...slug].astro
Expand All @@ -24,6 +24,23 @@ const htmlModules = import.meta.glob('../../samples/**/index.html', {
eager: true,
}) as Record<string, string>;

// Raw entry-module source for every sample. Used only to learn which
// stylesheets each sample imports, and in what order.
const tsSources = import.meta.glob('../../samples/**/src/index.ts', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;

// Every sample-local stylesheet, compiled to plain CSS text (SCSS included).
// These are inlined into <head> so the sample is styled at first paint instead
// of after its (multi-megabyte) JS chunk finally evaluates.
const sampleCss = import.meta.glob('../../samples/**/src/*.{css,scss}', {
query: '?inline',
import: 'default',
eager: true,
}) as Record<string, string>;

const PKG_PREFIX = '../../samples/';
const HTML_SUFFIX = '/index.html';

Expand Down Expand Up @@ -55,10 +72,26 @@ const info = slugToInfo(slug)!;
const rawHtml = htmlModules[`${PKG_PREFIX}${slug}${HTML_SUFFIX}`] ?? '';
const sampleHtml = extractSampleHtml(rawHtml);

// Resolve this sample's stylesheets so SampleLayout can put them in <head>.
// Theme sheets become <link>s to public/ig-themes/; sample-local files are
// inlined. Anything that fails to resolve is dropped here and left to the
// sample module's own import, so a miss degrades to the old behaviour rather
// than losing the styles.
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
const styles = resolveSampleStyles(tsSources[`${PKG_PREFIX}${slug}/src/index.ts`] ?? '', slug, base)
.map(style =>
style.kind === 'link'
? style
: sampleCss[style.key] !== undefined
? { kind: 'inline' as const, css: sampleCss[style.key] }
: null,
)
.filter(v => v !== null);

const pageTitle = `${info.displayName} | ${info.componentName} | Ignite UI Web Components`;
---

<SampleLayout slug={info.slug} title={pageTitle}>
<SampleLayout slug={info.slug} title={pageTitle} styles={styles}>
<Fragment set:html={sampleHtml} />
</SampleLayout>

Expand All @@ -73,9 +106,19 @@ const pageTitle = `${info.displayName} | ${info.componentName} | Ignite UI Web C
// Vite resolves this glob at build time → one lazy chunk per sample
const sampleModules = import.meta.glob('../../samples/**/src/index.ts');

// Lifts the layout.css guard that hides not-yet-upgraded custom elements.
// Must run on EVERY exit path, including failures — a sample that throws
// should still show whatever it managed to render, not stay blank.
function markReady() {
document.documentElement.dataset.sampleReady = '';
}

function loadSample() {
const slug = document.documentElement.dataset.sampleSlug;
if (!slug) return;
if (!slug) {
markReady();
return;
}

const moduleKey = `../../samples/${slug}/src/index.ts`;

Expand All @@ -91,10 +134,11 @@ const pageTitle = `${info.displayName} | ${info.componentName} | Ignite UI Web C
}
}).catch((err: unknown) => {
console.error(`[astro] failed to load sample module: ${moduleKey}`, err);
});
}).finally(markReady);
} else {
console.warn(`[astro] no sample module found for slug "${slug}". Key: ${moduleKey}`);
console.info('[astro] available keys:', Object.keys(sampleModules).slice(0, 5));
markReady();
}
}

Expand Down
Loading
Loading