diff --git a/jest.config.ts b/jest.config.ts index 377bcd83..1e515f0f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,6 +1,5 @@ import type {Config} from "jest"; import {availableParallelism} from "node:os"; -import {fileURLToPath} from "node:url"; const shared: Config = { rootDir: import.meta.dirname, @@ -85,12 +84,6 @@ const config: Config = { ...shared, displayName: "unit-dom", testEnvironment: "jsdom", - // Content builders use the import-only await-lock package; exercise its real implementation. - moduleNameMapper: { - ...shared.moduleNameMapper, - "^await-lock$": fileURLToPath(import.meta.resolve("await-lock")), - }, - transformIgnorePatterns: ["/node_modules/(?!await-lock/)"], testMatch: domTests, }, {...shared, displayName: "build", testMatch: buildTests}, diff --git a/package-lock.json b/package-lock.json index 4a78b61a..6d5e691a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,10 @@ "@rspack/core": "^1.7.5", "@rspack/lite-tapable": "^1.1.0", "@svgr/webpack": "^8.1.0", - "await-lock": "^3.0.0", "c12": "^3.3.2", "cac": "^6.7.14", "consola": "^3.4.2", "css-loader": "^7.1.2", - "debounce": "^2.2.0", "file-loader": "^6.2.0", "get-value": "^4.0.1", "html-rspack-tags-plugin": "^0.0.3", @@ -8948,12 +8946,6 @@ "retry": "0.13.1" } }, - "node_modules/await-lock": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-3.0.0.tgz", - "integrity": "sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==", - "license": "MIT" - }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -10482,18 +10474,6 @@ "node": ">=20" } }, - "node_modules/debounce": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", - "integrity": "sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index ebf9dd35..23838147 100644 --- a/package.json +++ b/package.json @@ -152,12 +152,10 @@ "@rspack/core": "^1.7.5", "@rspack/lite-tapable": "^1.1.0", "@svgr/webpack": "^8.1.0", - "await-lock": "^3.0.0", "c12": "^3.3.2", "cac": "^6.7.14", "consola": "^3.4.2", "css-loader": "^7.1.2", - "debounce": "^2.2.0", "file-loader": "^6.2.0", "get-value": "^4.0.1", "html-rspack-tags-plugin": "^0.0.3", diff --git a/src/cli/bundler/layers.ts b/src/cli/bundler/layers.ts index 246b9952..2960ce9f 100644 --- a/src/cli/bundler/layers.ts +++ b/src/cli/bundler/layers.ts @@ -1,14 +1,11 @@ import type {ContentScriptWorld} from "@typing/content"; -const ContentLayerPrefix = "adnbn:content:"; +// CSS classification is independent of the importing JavaScript module's layer. +// Default styles follow each entry's delivery policy; ?unisolated always targets the document. +export const DefaultStylesLayer = "adnbn:css:default"; +export const DocumentStylesLayer = "adnbn:css:document"; -// This is a bundler layer, not a CSS @layer. A distinct css-loader identity is -// also required: CssExtract deduplicates dependencies by their loader request. -export const IsolatedStylesLayer = "adnbn:css:isolation"; - -/** Shared build identity for content and Relay modules, independent of their UI isolation. */ +/** JavaScript identity depends on the execution world, never on CSS delivery. */ export const getContentLayer = (world: ContentScriptWorld): string => { - return `${ContentLayerPrefix}${world.toLowerCase()}`; + return `adnbn:content:${world.toLowerCase()}`; }; - -export const isContentLayer = (layer: string): boolean => layer.startsWith(ContentLayerPrefix); diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts index 736a0a92..5f2f470d 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.test.ts @@ -228,6 +228,7 @@ const compile = async ( betaCssChange?: boolean; buildHashSalt?: string; cssDir?: string; + cssChunkDir?: string; cssFullHashCallback?: boolean; fullHashCallback?: boolean; htmlChunks?: readonly string[]; @@ -269,6 +270,11 @@ const compile = async ( ? "[name].[app].[fullhash:8].css" : "[name].[app].[contenthash:8].css"; const cssFilename = appFilenameResolver(AppName, () => cssTemplate, options.cssDir ?? "custom/css"); + const cssChunkFilename = appFilenameResolver( + AppName, + () => cssTemplate, + options.cssChunkDir ?? options.cssDir ?? "custom/css" + ); const assetFilename = appFilenameResolver(AppName, () => "[name].[app].[contenthash:8][ext]", "custom/assets"); const background = options.background === "async" @@ -395,7 +401,7 @@ const compile = async ( : false, }, plugins: [ - new CssExtractRspackPlugin({filename: cssFilename, chunkFilename: cssFilename}), + new CssExtractRspackPlugin({filename: cssFilename, chunkFilename: cssChunkFilename}), ...(options.betaCssChange ? [ new NormalModuleReplacementPlugin(/beta\.css$/, resource => { @@ -429,7 +435,7 @@ const compile = async ( new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, buildHashSalt: options.buildHashSalt, - cssChunkFilename: cssFilename, + cssChunkFilename, cssFilename, fullMapEntrypoint: "background", }), @@ -928,20 +934,23 @@ describe("BuildAssetsMapPlugin", () => { expectNoChangedBytesUnderStableNames(before, after); }); - test("does not invent a JavaScript file for a CSS-only shared chunk", async () => { - const build = await compile(true, {sharedCssChunk: true}); - const sharedCss = build.assets.alpha.initial.css.find(file => build.assets.beta.initial.css.includes(file)); - - expect(sharedCss).toMatch(/^custom\/css\/shared-styles\..+\.css$/); - expect(build.assets.alpha.initial.js.some(file => file.includes("shared-styles"))).toBe(false); - expect(build.assets.beta.initial.js.some(file => file.includes("shared-styles"))).toBe(false); - expect(executeEntrypoint(build, "alpha").sandbox.__alphaEntrypointAssets).toEqual( - runtimeAssets(build.assets.alpha) - ); - expect(executeEntrypoint(build, "beta").sandbox.__betaEntrypointAssets).toEqual( - runtimeAssets(build.assets.beta) - ); - }); + test.each(["custom/css", "lazy/css"])( + "maps CSS-only initial shared chunks with async directory %s", + async cssChunkDir => { + const build = await compile(true, {sharedCssChunk: true, cssChunkDir}); + const sharedCss = build.assets.alpha.initial.css.find(file => build.assets.beta.initial.css.includes(file)); + + expect(sharedCss).toMatch(/^custom\/css\/shared-styles\..+\.css$/); + expect(build.assets.alpha.initial.js.some(file => file.includes("shared-styles"))).toBe(false); + expect(build.assets.beta.initial.js.some(file => file.includes("shared-styles"))).toBe(false); + expect(executeEntrypoint(build, "alpha").sandbox.__alphaEntrypointAssets).toEqual( + runtimeAssets(build.assets.alpha) + ); + expect(executeEntrypoint(build, "beta").sandbox.__betaEntrypointAssets).toEqual( + runtimeAssets(build.assets.beta) + ); + } + ); test("keeps configured CSS directories inside callback-generated names", async () => { const [css, styles] = await Promise.all([ diff --git a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts index 78c79219..17ac76b3 100644 --- a/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts +++ b/src/cli/bundler/plugins/build-assets-map/BuildAssetsMapPlugin.ts @@ -3,7 +3,10 @@ import stringify from "json-stringify-deterministic"; import ts from "typescript"; import {collectBuildAssets} from "@cli/bundler/utils/assets"; +import {normalizeAssets, normalizeAssetsMap} from "./normalize"; +import {renderBuildAssetsRuntime} from "./templates"; import {setCompilationBuildAssets} from "../utils"; + import { CssContentHashType, JavaScriptContentHashType, @@ -11,6 +14,7 @@ import { resolveFilenameTemplate, filenameRequiresFullHash, } from "@cli/bundler/utils/chunk-filename"; + import {toPosix} from "@cli/utils/path"; import type {EntrypointAssetsMap, EntrypointAssets} from "@typing/entrypoint"; @@ -54,6 +58,7 @@ const validateAsyncChunkFilenames = (compilation: Compilation, cssChunkFilename: return Array.from(entrypoint.getEntrypointChunk().getAllAsyncChunks()); }) ); + const validate = ( filename: Filename, chunks: Iterable, @@ -89,6 +94,7 @@ const validateAsyncChunkFilenames = (compilation: Compilation, cssChunkFilename: "jsFilename" ); } + validate( cssChunkFilename, Array.from(asyncChunks).filter(chunk => chunkHasCss(compilation, chunk)), @@ -107,7 +113,10 @@ const chunkHasCss = (compilation: Compilation, chunk: Chunk): boolean => { const chunkHasJavaScript = (compilation: Compilation, chunk: Chunk): boolean => { // splitChunks can move every user module out of an entry, but its bootstrap // still emits a JavaScript file and remains an initial dependency. - if (chunk.hasRuntime()) return true; + if (chunk.hasRuntime()) { + return true; + } + return Array.from(compilation.chunkGraph.getChunkModulesIterable(chunk)).some(module => { return module.type.startsWith("javascript"); }); @@ -153,7 +162,7 @@ const getEntrypointRuntimeAssets = ( return resolveChunkFilename( compilation, chunk, - chunk === entryChunk ? cssFilename : cssChunkFilename, + chunk.canBeInitial() ? cssFilename : cssChunkFilename, CssContentHashType ); }), @@ -203,12 +212,13 @@ const entrypointRuntimeUsesFullHash = ( chunk === entryChunk ? jsFilename : jsChunkFilename, JavaScriptContentHashType ); + const css = chunkHasCss(compilation, chunk) && filenameRequiresFullHash( compilation, chunk, - chunk === entryChunk ? cssFilename : cssChunkFilename, + chunk.canBeInitial() ? cssFilename : cssChunkFilename, CssContentHashType ); @@ -216,6 +226,7 @@ const entrypointRuntimeUsesFullHash = ( }) || asyncChunks.some(chunk => { const js = filenameRequiresFullHash(compilation, chunk, jsChunkFilename, JavaScriptContentHashType); + const css = chunkHasCss(compilation, chunk) && filenameRequiresFullHash(compilation, chunk, cssChunkFilename, CssContentHashType); @@ -251,6 +262,7 @@ const getFullMapRuntimeFiles = (fullMap: FullMapRuntime, assets: EntrypointAsset const validateEntrypointRuntime = (compilation: Compilation, name: string): void => { const entrypoint = compilation.entrypoints.get(name)!; + if (entrypoint.getRuntimeChunk() !== entrypoint.getEntrypointChunk()) { throw new Error( `Build assets require a self-contained runtime for entrypoint "${name}"; disable optimization.runtimeChunk` @@ -322,7 +334,10 @@ const updateBuildAssetsChunkHashes = ( runtimes: ReadonlySet ): void => { compilation.hooks.chunkHash.tap(PluginName, (chunk, hash) => { - if (!runtimes.has(chunk)) return; + if (!runtimes.has(chunk)) { + return; + } + const runtimeEntry = Array.from(compilation.entrypoints).find(([, entrypoint]) => { return entrypoint.getRuntimeChunk() === chunk; }); @@ -333,6 +348,7 @@ const updateBuildAssetsChunkHashes = ( const [runtimeName] = runtimeEntry; const fullMap = runtimeName === options.fullMapEntrypoint; + const entries = fullMap ? Array.from(compilation.entrypoints).toSorted(([left], [right]) => left.localeCompare(right)) : [runtimeEntry]; @@ -353,6 +369,7 @@ const updateBuildAssetsChunkHashes = ( const entryChunk = entrypoint.getEntrypointChunk(); const initialChunks = new Set(entrypoint.chunks); + const chunks = Array.from(entryChunk.getAllReferencedChunks()).toSorted((left, right) => getChunkKey(left).localeCompare(getChunkKey(right)) ); @@ -379,7 +396,8 @@ const updateBuildAssetsChunkHashes = ( } if (chunkHasCss(compilation, referencedChunk)) { - const cssFilename = referencedChunk === entryChunk ? options.cssFilename : options.cssChunkFilename; + const cssFilename = referencedChunk.canBeInitial() ? options.cssFilename : options.cssChunkFilename; + const template = resolveFilenameTemplate( compilation, referencedChunk, @@ -435,7 +453,13 @@ class BuildAssetsRuntimeModule extends RuntimeModule { const runtime = RuntimeGlobals.require; if (this.fullMap) { - return `${runtime}[${JSON.stringify(this.property)}] = JSON.parse(${JSON.stringify(FullMapPlaceholder)}).${FullMapEnvelopeKey};`; + return renderBuildAssetsRuntime({ + require: runtime, + property: this.property, + value: FullMapPlaceholder, + envelope: FullMapEnvelopeKey, + full: true, + }); } const compilation = this.compilation; @@ -453,7 +477,13 @@ class BuildAssetsRuntimeModule extends RuntimeModule { const envelope = JSON.stringify({[CurrentMapEnvelopeKey]: current}); - return `${runtime}[${JSON.stringify(this.property)}] = JSON.parse(${JSON.stringify(envelope)}).${CurrentMapEnvelopeKey};`; + return renderBuildAssetsRuntime({ + require: runtime, + property: this.property, + value: envelope, + envelope: CurrentMapEnvelopeKey, + full: false, + }); } } @@ -529,6 +559,7 @@ const extractEmbeddedMap = (file: string, source: string): EntrypointAssetsMap = const validateEmbeddedMap = (compilation: Compilation, fullMap: FullMapRuntime, assets: EntrypointAssetsMap): void => { const runtimeFiles = getFullMapRuntimeFiles(fullMap, assets); + const runtime = runtimeFiles .map(file => ({file, source: compilation.getAsset(file)?.source.source().toString() ?? ""})) .find(({source}) => source.length > 0); @@ -541,7 +572,7 @@ const validateEmbeddedMap = (compilation: Compilation, fullMap: FullMapRuntime, const embedded = extractEmbeddedMap(runtime.file, runtime.source); - if (stringify(embedded) !== stringify(assets)) { + if (stringify(normalizeAssetsMap(embedded)) !== stringify(assets)) { throw new Error( "Build assets changed after the runtime map was embedded; finalize entrypoint assets before processAssets begins" ); @@ -565,9 +596,12 @@ const validateCurrentEntrypointMaps = ( throw new Error(`Build assets entrypoint "${name}" is unavailable`); } - if (!runtimes.has(entrypoint.getRuntimeChunk())) continue; + if (!runtimes.has(entrypoint.getRuntimeChunk())) { + continue; + } const runtimeFiles = new Set(Array.from(entrypoint.getRuntimeChunk().files, toPosix)); + const runtime = entrypointAssets.initial.js .filter(file => runtimeFiles.has(file)) .map(file => ({file, source: compilation.getAsset(file)?.source.source().toString() ?? ""})) @@ -582,12 +616,13 @@ const validateCurrentEntrypointMaps = ( runtime.source, CurrentMapEnvelopeKey ); + const finalizedAssets: EntrypointAssets = { initial: entrypointAssets.initial, async: entrypointAssets.async, }; - if (stringify(runtimeAssets) !== stringify(finalizedAssets)) { + if (stringify(normalizeAssets(runtimeAssets)) !== stringify(finalizedAssets)) { throw new Error( `Build assets changed after the runtime map for entrypoint "${name}" was embedded: ${JSON.stringify({embedded: runtimeAssets, finalized: finalizedAssets})}` ); @@ -644,10 +679,17 @@ export default class BuildAssetsMapPlugin { const runtime = entrypoint.getRuntimeChunk(); const fullMap = name === this.options.fullMapEntrypoint; const delivery = fullMap ? this.options.module.full : this.options.module.current; - if (!isUsed(runtime, delivery.export)) continue; + + if (!isUsed(runtime, delivery.export)) { + continue; + } validateEntrypointRuntime(compilation, name); - if (fullMap) validateFullMapEntrypoint(compilation, name); + + if (fullMap) { + validateFullMapEntrypoint(compilation, name); + } + selectedRuntimes.set(runtime, {name, fullMap, property: delivery.property}); } } @@ -658,10 +700,14 @@ export default class BuildAssetsMapPlugin { } const selection = selectedRuntimes.get(chunk); - if (!selection || injectedRuntimes.has(chunk)) return; + + if (!selection || injectedRuntimes.has(chunk)) { + return; + } requirements.add(RuntimeGlobals.require); injectedRuntimes.add(chunk); + compilation.addRuntimeModule( chunk, new BuildAssetsRuntimeModule( @@ -714,7 +760,10 @@ export default class BuildAssetsMapPlugin { {name: `${PluginName}:finalValidation`, stage: FinalValidationStage}, compilation => { const runtimes = runtimeSelections.get(compilation); - if (runtimes) finalizeBuildAssets(compilation, this.options, runtimes); + + if (runtimes) { + finalizeBuildAssets(compilation, this.options, runtimes); + } } ); }, diff --git a/src/cli/bundler/plugins/build-assets-map/README.md b/src/cli/bundler/plugins/build-assets-map/README.md index c7921512..7b147d4c 100644 --- a/src/cli/bundler/plugins/build-assets-map/README.md +++ b/src/cli/bundler/plugins/build-assets-map/README.md @@ -32,3 +32,9 @@ definitions; it has no knowledge of content or entrypoint asset properties. Asset collection and classification live in `bundler/utils/assets.ts`. The per-compilation result is shared with manifest generation through `bundler/plugins/utils/compilation-assets.ts`. Filename template handling is independent in `bundler/utils/app-filename.ts` and `chunk-filename.ts`. + +Contenthash-only filenames can merge byte-identical CSS chunks used in different destinations. +The owning runtime normalizes filename lists after hash substitution: duplicates are removed without +changing initial dependency order, and files already initial are omitted from async lists. Final +validation compares that same canonical inventory. No emitted-file rewrite or public getter logic +is needed for this normalization. diff --git a/src/cli/bundler/plugins/build-assets-map/normalize.ts b/src/cli/bundler/plugins/build-assets-map/normalize.ts new file mode 100644 index 00000000..b515ccdf --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/normalize.ts @@ -0,0 +1,26 @@ +import type {EntrypointAssets, EntrypointAssetsMap} from "@typing/entrypoint"; + +// realContentHash may merge different chunk filenames into one emitted file. +export const normalizeAssets = (assets: T): T => { + const initial = { + js: [...new Set(assets.initial.js)], + css: [...new Set(assets.initial.css)], + }; + + const initialFiles = new Set([...initial.js, ...initial.css]); + + const async = { + js: [...new Set(assets.async.js)].filter(file => !initialFiles.has(file)), + css: [...new Set(assets.async.css)].filter(file => !initialFiles.has(file)), + }; + + return { + ...assets, + initial, + async, + }; +}; + +export const normalizeAssetsMap = (assets: EntrypointAssetsMap): EntrypointAssetsMap => { + return Object.fromEntries(Object.entries(assets).map(([name, entry]) => [name, normalizeAssets(entry)])); +}; diff --git a/src/cli/bundler/plugins/build-assets-map/templates.ts b/src/cli/bundler/plugins/build-assets-map/templates.ts new file mode 100644 index 00000000..67160952 --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/templates.ts @@ -0,0 +1,20 @@ +import runtimeTemplate from "./templates/runtime.template.js?raw"; +import {renderRuntimeTemplate} from "@cli/bundler/plugins/utils"; +import type {RuntimePropertyOptions} from "../types"; + +interface BuildAssetsRuntimeTemplateOptions extends RuntimePropertyOptions { + readonly require: string; + readonly value: string; + readonly envelope: string; + readonly full: boolean; +} + +export const renderBuildAssetsRuntime = (options: BuildAssetsRuntimeTemplateOptions): string => { + return renderRuntimeTemplate(runtimeTemplate, { + __ADNBN_REQUIRE__: options.require, + __ADNBN_PROPERTY__: JSON.stringify(options.property), + __ADNBN_VALUE__: JSON.stringify(options.value), + __ADNBN_ENVELOPE__: JSON.stringify(options.envelope), + __ADNBN_FULL_MAP__: JSON.stringify(options.full), + }); +}; diff --git a/src/cli/bundler/plugins/build-assets-map/templates/runtime.template.js b/src/cli/bundler/plugins/build-assets-map/templates/runtime.template.js new file mode 100644 index 00000000..85763d0b --- /dev/null +++ b/src/cli/bundler/plugins/build-assets-map/templates/runtime.template.js @@ -0,0 +1,14 @@ +var assets = JSON.parse(__ADNBN_VALUE__)[__ADNBN_ENVELOPE__]; +var entries = __ADNBN_FULL_MAP__ ? Object.keys(assets).map(name => assets[name]) : [assets]; + +// realContentHash can merge multiple chunk filenames into one emitted file. +// Normalize after those substitutions, preserving the dependency order. +entries.forEach(entry => { + entry.initial.js = Array.from(new Set(entry.initial.js)); + entry.initial.css = Array.from(new Set(entry.initial.css)); + var initial = new Set(entry.initial.js.concat(entry.initial.css)); + entry.async.js = Array.from(new Set(entry.async.js)).filter(file => !initial.has(file)); + entry.async.css = Array.from(new Set(entry.async.css)).filter(file => !initial.has(file)); +}); + +__ADNBN_REQUIRE__[__ADNBN_PROPERTY__] = assets; diff --git a/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts index 396927ee..b65bacf8 100644 --- a/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts +++ b/src/cli/bundler/plugins/entrypoint/EntrypointPlugin.ts @@ -1,7 +1,7 @@ import _ from "lodash"; import path from "path"; -import {Compiler, type EntryDescription, EntryNormalized} from "@rspack/core"; +import {Compiler, DynamicEntryPlugin, type EntryDescription, EntryNormalized} from "@rspack/core"; import {RspackVirtualModulePlugin as VirtualModulesPlugin} from "rspack-plugin-virtual-module"; import {EntrypointEntries, EntrypointFile} from "@typing/entrypoint"; @@ -106,6 +106,24 @@ export default class EntrypointPlugin { }); if (this.update) { + // Static EntryPlugin captures options once. Resolve the completed entry table + // per compilation so watch changes to layers and asyncChunks reach Rspack too. + // Register after other plugins have prepared their entryOption hooks, but + // before Rspack installs its default static entry handler. + compiler.hooks.afterEnvironment.tap(this.pluginName, () => { + compiler.hooks.entryOption.tap(`${this.pluginName}:watch`, (context, entry) => { + compiler.options.entry = entry; + + new DynamicEntryPlugin(context, async () => { + const entry = compiler.options.entry; + + return typeof entry === "function" ? entry() : entry; + }).apply(compiler); + + return true; + }); + }); + compiler.hooks.watchRun.tapAsync(this.pluginName, (compiler, callback) => { this.hookWatchRun(compiler) .then(() => callback()) diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts index ff3c845b..a5314865 100644 --- a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts @@ -7,7 +7,7 @@ import vm from "vm"; import {type Compiler, CssExtractRspackPlugin, type Filename, rspack, type Stats} from "@rspack/core"; -import {IsolatedStylesLayer} from "@cli/bundler/layers"; +import {DefaultStylesLayer, DocumentStylesLayer} from "@cli/bundler/layers"; import IsolatedStylesPlugin, { type IsolatedStylesPluginOptions, @@ -82,6 +82,7 @@ interface CompileOptions { context?: string; filename?: Filename; cssFilename?: Filename; + cssChunkFilename?: Filename; sharedInitial?: boolean; mode?: "development" | "production"; selected?: (entry: string) => boolean; @@ -92,6 +93,7 @@ const createCompiler = (outputPath: string, options: CompileOptions = {}): Compi const filename = options.filename ?? "js/[name].[contenthash:8].js"; const cssFilename = options.cssFilename ?? "css/[name].[contenthash:8].css"; + const cssChunkFilename = options.cssChunkFilename ?? cssFilename; return rspack({ context: options.context ?? fixtures, @@ -117,7 +119,8 @@ const createCompiler = (outputPath: string, options: CompileOptions = {}): Compi }, module: { rules: [ - {test: /shadow.*\.css$/, layer: IsolatedStylesLayer}, + {test: /shadow.*\.css$/, layer: DefaultStylesLayer}, + {test: /normal.*\.css$/, layer: DocumentStylesLayer}, { test: /\.css$/i, sideEffects: true, @@ -152,11 +155,11 @@ const createCompiler = (outputPath: string, options: CompileOptions = {}): Compi plugins: [ new CssExtractRspackPlugin({ filename: cssFilename, - chunkFilename: cssFilename, + chunkFilename: cssChunkFilename, }), new IsolatedStylesPlugin({ cssFilename, - cssChunkFilename: cssFilename, + cssChunkFilename, property: options.property ?? RuntimeProperty, validate: options.validate, test: options.selected ?? (entry => entry === "shadow"), @@ -191,12 +194,11 @@ const compile = async (options: CompileOptions = {}): Promise => { }; test.each(["./shadow.js", "./shadow-async.js"])( - "reports initial and async CSS independently of runtime selection (%s)", + "reports initial and async CSS using the entry delivery policy (%s)", async shadowEntry => { const validated = new Map(); const result = await compile({ shadowEntry, - selected: () => false, validate: (entry, files) => { validated.set(entry, files); }, @@ -214,9 +216,10 @@ test.each(["./shadow.js", "./shadow-async.js"])( await expect( compile({ shadowEntry, - selected: () => false, validate: (entry, files) => { - if (files.isolated.length) throw new Error(`Consumer policy rejects styles for "${entry}"`); + if (files.isolated.length) { + throw new Error(`Consumer policy rejects styles for "${entry}"`); + } }, }) ).rejects.toThrow('Consumer policy rejects styles for "shadow"'); @@ -234,16 +237,36 @@ test("reports shared styles to both entries without duplicate filenames", async try { const shared = validated.get("shadow")!.isolated.find(file => /shared-styles\./.test(file)); expect(shared).toBeDefined(); - expect(validated.get("normal")!.isolated).toEqual([shared]); + expect(validated.get("normal")!.isolated).toEqual([]); + expect(validated.get("normal")!.document).toContain(shared); expect(validated.get("shadow")!.isolated).toHaveLength(2); } finally { await result.close(); } }); +test("rejects a mixed chunk only when it has isolated delivery and splitting is disabled", async () => { + const ordinary = await compile({shadowEntry: "./mixed.js", selected: () => false}); + + try { + expect( + ordinary.stats.compilation.entrypoints + .get("shadow")! + .getFiles() + .filter(file => file.endsWith(".css")) + ).toHaveLength(1); + } finally { + await ordinary.close(); + } + + await expect(compile({shadowEntry: "./mixed.js"})).rejects.toThrow( + 'CSS chunk "shadow" mixes document and default styles for isolated delivery. Preserve the adnbnDocumentStyles CSS cache group' + ); +}); + test("refreshes warnings between compilations and supports the bundler warning filter", async () => { let warn = true; - const message = '[fixture:missing-isolation-css] Entrypoint "shadow"'; + const message = '[fixture:style-policy] Entrypoint "shadow"'; const validate: IsolatedStylesPluginOptions["validate"] = (entry, files) => warn && entry === "shadow" && files.document.length > 0 && files.isolated.length === 0 ? [message] : []; const result = await compile({shadowEntry: "./normal.js", validate}); @@ -275,7 +298,7 @@ test("refreshes warnings between compilations and supports the bundler warning f const suppressed = await compile({ shadowEntry: "./normal.js", validate, - ignoreWarnings: [/fixture:missing-isolation-css/], + ignoreWarnings: [/fixture:style-policy/], }); try { expect(suppressed.stats.toJson({all: false, warnings: true}).warnings).toEqual([]); @@ -755,28 +778,36 @@ test.each(["contenthash", "chunkhash", "fullhash"])( } ); -test("owns multiple initial files in bundler order, including a shared CSS chunk, and initializes once", async () => { - const result = await compile({sharedInitial: true}); - try { - const harness = executeShadowEntrypoint(result); - const resolveAgain = jest.fn(); - harness.runtime.initialize(resolveAgain); - expect(resolveAgain).not.toHaveBeenCalled(); - const root = createRoot(link => queueMicrotask(() => link.onload?.())); - harness.runtime.add(root, {}); - const initial = result.stats.compilation.entrypoints - .get("shadow")! - .getFiles() - .filter(file => file.endsWith(".css")); - expect(initial).toHaveLength(2); - expect(root.links.map(link => link.href)).toEqual(initial.map(file => `https://extension.test/${file}`)); - const normal = result.stats.compilation.entrypoints.get("normal")!.getFiles(); - expect(initial.some(file => normal.includes(file))).toBe(true); - harness.runtime.delete(root); - } finally { - await result.close(); +test.each(["contenthash", "chunkhash", "fullhash", "callback"])( + "owns initial shared CSS with distinct filename templates (%s)", + async hash => { + const template = `initial/[name].[${hash === "callback" ? "contenthash" : hash}:8].css`; + const result = await compile({ + sharedInitial: true, + cssFilename: hash === "callback" ? () => template : template, + cssChunkFilename: "lazy/[name].[contenthash:8].css", + }); + try { + const harness = executeShadowEntrypoint(result); + const resolveAgain = jest.fn(); + harness.runtime.initialize(resolveAgain); + expect(resolveAgain).not.toHaveBeenCalled(); + const root = createRoot(link => queueMicrotask(() => link.onload?.())); + harness.runtime.add(root, {}); + const initial = result.stats.compilation.entrypoints + .get("shadow")! + .getFiles() + .filter(file => file.endsWith(".css")); + expect(initial).toHaveLength(2); + expect(root.links.map(link => link.href)).toEqual(initial.map(file => `https://extension.test/${file}`)); + const normal = result.stats.compilation.entrypoints.get("normal")!.getFiles(); + expect(initial.some(file => normal.includes(file))).toBe(true); + harness.runtime.delete(root); + } finally { + await result.close(); + } } -}); +); test.each(["contenthash", "chunkhash", "fullhash", "callback"])( "refreshes initial CSS URLs after editing CSS across %s rebuilds", diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts index 50500f33..51e82631 100644 --- a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.ts @@ -5,7 +5,8 @@ import type {RuntimePropertyOptions} from "../types"; import {getManifestHooks} from "../utils/manifest-hooks"; import {renderIsolatedStylesCssLoader, renderIsolatedStylesRuntime} from "./templates"; -import {getIsolatedStylesFiles, isIsolatedStylesChunk} from "./chunks"; +import {getStylesFiles, isIsolatedStylesChunk} from "./chunks"; +import {createStylesCacheGroup, DocumentStylesCacheGroup} from "./split-chunks"; const PluginName = "IsolatedStylesPlugin"; const CssLoadingRuntimeIdentifier = "webpack/runtime/css loading"; @@ -17,7 +18,7 @@ export interface IsolatedStylesPluginOptions extends RuntimePropertyOptions { cssChunkFilename: Filename; test: (entry: string) => boolean; timeout?: number; - /** Inspects every entry's initial, shared and async CSS, independently of test. Throw to reject; return warning messages to report without changing delivery. */ + /** Inspects every entry's initial, shared and async CSS using its delivery policy. Throw to reject; return warning messages without changing delivery. */ validate?: (entry: string, files: IsolatedStylesPluginFiles) => void | readonly string[]; } @@ -43,7 +44,10 @@ class IsolatedStylesRuntimeModule extends RuntimeModule { public generate(): string { const compilation = this.compilation; - if (!compilation) throw new Error("Isolated styles runtime is not attached to a compilation"); + + if (!compilation) { + throw new Error("Isolated styles runtime is not attached to a compilation"); + } const initialStyles = initialStyleChunks(compilation, this.entry, this.cssFilename, this.cssChunkFilename).map( ({chunk, filename}) => resolveChunkFilename(compilation, chunk, filename, CssContentHashType) @@ -66,12 +70,14 @@ const initialStyleChunks = ( cssChunkFilename: Filename ): {chunk: Chunk; filename: Filename}[] => { const entrypoint = compilation.entrypoints.get(entry); - if (!entrypoint) throw new Error(`Isolated styles entrypoint "${entry}" is unavailable`); - const entryChunk = entrypoint.getEntrypointChunk(); + if (!entrypoint) { + throw new Error(`Isolated styles entrypoint "${entry}" is unavailable`); + } + return Array.from(entrypoint.chunks) .filter(chunk => isIsolatedStylesChunk(compilation, chunk)) - .map(chunk => ({chunk, filename: chunk === entryChunk ? cssFilename : cssChunkFilename})); + .map(chunk => ({chunk, filename: chunk.canBeInitial() ? cssFilename : cssChunkFilename})); }; const hasAsyncCss = (chunk: Chunk): boolean => { @@ -86,15 +92,39 @@ export default class IsolatedStylesPlugin { public constructor(private readonly options: IsolatedStylesPluginOptions) {} public apply(compiler: Compiler): void { + const cacheGroup = createStylesCacheGroup(compiler, this.options.test); + + // Defaults are resolved before this hook; SplitChunksPlugin is configured afterwards. + compiler.hooks.afterEnvironment.tap(PluginName, () => { + const splitChunks = compiler.options.optimization.splitChunks; + + if (splitChunks) { + splitChunks.cacheGroups = { + [DocumentStylesCacheGroup]: cacheGroup, + ...splitChunks.cacheGroups, + }; + } + }); + compiler.hooks.compilation.tap(PluginName, compilation => { getManifestHooks(compilation).prepareDependencies.tap(PluginName, dependencies => { - const isolatedFiles = getIsolatedStylesFiles(compilation); for (const [entry, dependency] of dependencies) { - if (!this.options.test(entry)) continue; + if (!this.options.test(entry)) { + continue; + } + + const files = getStylesFiles(compilation, compilation.entrypoints.get(entry)?.chunks ?? []); for (const file of dependency.css) { - if (!isolatedFiles.has(file)) continue; - dependency.css.delete(file); + if (!files.defaults.has(file)) { + continue; + } + + // Identical CSS can have the same contenthash filename in both destinations. + if (!files.document.has(file)) { + dependency.css.delete(file); + } + dependency.assets.add(file); } } @@ -115,6 +145,7 @@ export default class IsolatedStylesPlugin { requirements.add(RuntimeGlobals.require); requirements.add(RuntimeGlobals.publicPath); injected.add(chunk); + const fullHash = initialStyleChunks( compilation, chunk.name, @@ -123,6 +154,7 @@ export default class IsolatedStylesPlugin { ).some(({chunk, filename}) => filenameRequiresFullHash(compilation, chunk, filename, CssContentHashType) ); + compilation.addRuntimeModule( chunk, new IsolatedStylesRuntimeModule( @@ -170,7 +202,7 @@ export default class IsolatedStylesPlugin { source.source = renderIsolatedStylesCssLoader({ isolatedChunks: Object.fromEntries( - [...compilation.chunks] + [...chunk.getAllReferencedChunks()] .filter(candidate => isIsolatedStylesChunk(compilation, candidate)) .map(candidate => [candidate.id, true]) ), @@ -181,6 +213,7 @@ export default class IsolatedStylesPlugin { require: RuntimeGlobals.require, property: this.options.property, }); + patched.add(chunk); }); @@ -191,21 +224,33 @@ export default class IsolatedStylesPlugin { if (this.options.validate) { const document = new Set(); const isolated = new Set(); + const referenced = new Set([ ...entrypoint.chunks, ...entrypoint.getEntrypointChunk().getAllReferencedChunks(), ]); + for (const candidate of referenced) { - const destination = isIsolatedStylesChunk(compilation, candidate) ? isolated : document; + const destination = + this.options.test(entry) && isIsolatedStylesChunk(compilation, candidate) + ? isolated + : document; + for (const file of candidate.files) { - if (file.endsWith(".css")) destination.add(file); + if (file.endsWith(".css")) { + destination.add(file); + } } } + const warnings = this.options.validate(entry, { document: [...document].sort(), isolated: [...isolated].sort(), }); - for (const message of warnings ?? []) compilation.warnings.push(new Error(message)); + + for (const message of warnings ?? []) { + compilation.warnings.push(new Error(message)); + } } if ( diff --git a/src/cli/bundler/plugins/isolated-styles/chunks.ts b/src/cli/bundler/plugins/isolated-styles/chunks.ts index 220c6c28..de0093ef 100644 --- a/src/cli/bundler/plugins/isolated-styles/chunks.ts +++ b/src/cli/bundler/plugins/isolated-styles/chunks.ts @@ -1,38 +1,43 @@ import type {Chunk, Compilation, Module} from "@rspack/core"; -import {IsolatedStylesLayer} from "@cli/bundler/layers"; +import {DocumentStylesLayer} from "@cli/bundler/layers"; +import {DocumentStylesCacheGroup} from "./split-chunks"; -const isIsolatedStylesModule = (module: Module): boolean => - module.type === "css/mini-extract" && module.layer === IsolatedStylesLayer; +const isDocumentStylesModule = (module: Module): boolean => module.layer === DocumentStylesLayer; +/** Only call for chunks reachable by an entry with isolated delivery. Other entries may mix CSS categories. */ export const isIsolatedStylesChunk = (compilation: Compilation, chunk: Chunk): boolean => { const modules = [...compilation.chunkGraph.getChunkModulesIterable(chunk)].filter( module => module.type === "css/mini-extract" ); - const isolated = modules.some(isIsolatedStylesModule); + const document = modules.some(isDocumentStylesModule); - if (isolated && !modules.every(isIsolatedStylesModule)) { + if (document && !modules.every(isDocumentStylesModule)) { throw new Error( - `CSS chunk "${chunk.name ?? chunk.id}" mixes document and ?isolation styles. Preserve the adnbnIsolatedStyles CSS cache group in your bundler configuration.` + `CSS chunk "${chunk.name ?? chunk.id}" mixes document and default styles for isolated delivery. Preserve the ${DocumentStylesCacheGroup} CSS cache group in your bundler configuration.` ); } - return isolated; + return modules.length > 0 && !document; }; -export const getIsolatedStylesFiles = (compilation: Compilation): ReadonlySet => { - const files = new Set(); +export const getStylesFiles = ( + compilation: Compilation, + chunks: Iterable +): {document: ReadonlySet; defaults: ReadonlySet} => { + const document = new Set(); + const defaults = new Set(); - for (const chunk of compilation.chunks) { - if (isIsolatedStylesChunk(compilation, chunk)) { - for (const file of chunk.files) { - if (file.endsWith(".css")) { - files.add(file); - } + for (const chunk of chunks) { + const files = isIsolatedStylesChunk(compilation, chunk) ? defaults : document; + + for (const file of chunk.files) { + if (file.endsWith(".css")) { + files.add(file); } } } - return files; + return {document, defaults}; }; diff --git a/src/cli/bundler/plugins/isolated-styles/split-chunks.ts b/src/cli/bundler/plugins/isolated-styles/split-chunks.ts new file mode 100644 index 00000000..e760bf6d --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/split-chunks.ts @@ -0,0 +1,69 @@ +import type {Chunk, ChunkGroup, Compilation, Compiler, OptimizationSplitChunksCacheGroup} from "@rspack/core"; + +import {DocumentStylesLayer} from "@cli/bundler/layers"; + +export const DocumentStylesCacheGroup = "adnbnDocumentStyles"; + +/** Partition CSS only where an entry needs separate document and isolated delivery. */ +export const createStylesCacheGroup = ( + compiler: Compiler, + test: (entry: string) => boolean +): OptimizationSplitChunksCacheGroup => { + let compilation: Compilation; + let selectedGroups = new WeakMap(); + + compiler.hooks.thisCompilation.tap("IsolatedStylesPlugin", current => { + compilation = current; + selectedGroups = new WeakMap(); + }); + + const selected = (group: ChunkGroup): boolean => { + const cached = selectedGroups.get(group); + + if (cached !== undefined) { + return cached; + } + + const pending = [group]; + const visited = new Set(); + + while (pending.length) { + const parent = pending.pop()!; + + if (visited.has(parent)) { + continue; + } + + visited.add(parent); + + if (parent.isInitial() && parent.name !== undefined && test(parent.name)) { + selectedGroups.set(group, true); + + return true; + } + + pending.push(...parent.getParents()); + } + + selectedGroups.set(group, false); + + return false; + }; + + return { + type: "css/mini-extract", + layer: DocumentStylesLayer, + chunks: (chunk: Chunk): boolean => { + const modules = compilation.chunkGraph.getChunkModulesIterable(chunk); + + if (![...modules].some(module => module.type === "css/mini-extract")) { + return false; + } + + return [...chunk.groupsIterable].some(selected); + }, + enforce: true, + name: false, + priority: 100, + }; +}; diff --git a/src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js b/src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js index a71c5058..ced9af37 100644 --- a/src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js +++ b/src/cli/bundler/plugins/isolated-styles/templates/runtime.template.js @@ -21,6 +21,8 @@ var attachStyle = function (root, url, retry) { if (existing) return existing.promise; + rootState.order.add(url); + var resolveStyle; var rejectStyle; var promise = new Promise(function (resolve, reject) { @@ -48,6 +50,7 @@ var attachStyle = function (root, url, retry) { if (error) { rootState.styles.delete(url); record.link.remove(); + console.error(error); rejectStyle(error); } else { resolveStyle(); @@ -74,8 +77,26 @@ var attachStyle = function (root, url, retry) { settle(createStyleError(url, type)); } }; - // Replace in place: appending a retry could change the CSS cascade order. - root.insertBefore(link, previous || rootState.target); + // A failed link may be retried after later styles succeeded. Preserve its original position. + var next = previous || rootState.target; + var after = false; + + if (!previous) { + for (var file of rootState.order) { + if (file === url) { + after = true; + } else if (after) { + var following = rootState.styles.get(file); + + if (following && following.link) { + next = following.link; + break; + } + } + } + } + + root.insertBefore(link, next); if (previous) previous.remove(); }; // One budget for both attempts, never another timer for the retry. @@ -97,14 +118,23 @@ __ADNBN_REQUIRE__[__ADNBN_PROPERTY__] = { if (!isolatedInitialStyles) throw new Error('Isolated stylesheet URLs for entrypoint "' + isolatedStyleEntry + '" are not initialized'); - isolatedStyleRoots.set(root, {target: target, styles: new Map()}); + isolatedStyleRoots.set(root, {target: target, styles: new Map(), order: new Set()}); isolatedInitialStyles.concat(Array.from(isolatedRequestedStyles)).forEach(function (url) { - attachStyle(root, url, retry).catch(function (error) { - console.error(error); - }); + attachStyle(root, url, retry).catch(function () {}); }); }, + ready: function (root) { + if (!isolatedStyleRoots.has(root)) { + return Promise.resolve(); + } + + return Promise.all( + isolatedInitialStyles.concat(Array.from(isolatedRequestedStyles)).map(function (url) { + return attachStyle(root, url); + }) + ).then(function () {}); + }, delete: function (root) { var rootState = isolatedStyleRoots.get(root); diff --git a/src/cli/bundler/plugins/isolated-styles/tests/fixtures/mixed.js b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/mixed.js new file mode 100644 index 00000000..86d6cd03 --- /dev/null +++ b/src/cli/bundler/plugins/isolated-styles/tests/fixtures/mixed.js @@ -0,0 +1,2 @@ +import "./shadow.css"; +import "./normal.css"; diff --git a/src/cli/plugins/content/bundler.test.ts b/src/cli/plugins/content/bundler.test.ts index 837dbb18..1c0777bf 100644 --- a/src/cli/plugins/content/bundler.test.ts +++ b/src/cli/plugins/content/bundler.test.ts @@ -1,49 +1,6 @@ -import {createPageAccessRequirements, validateContentStyles} from "./bundler"; +import {createPageAccessRequirements} from "./bundler"; import type {ContentScriptEntrypointOptions} from "@typing/content"; -describe("validateContentStyles", () => { - test.each(["panel.content", "panel.relay"])("warns for missing isolated CSS in %s", entry => { - for (const isolation of [{type: "shadow"}, {type: "iframe"}] as const) { - const warnings = validateContentStyles(entry, {isolation}, {document: ["host.css"], isolated: []}); - expect(warnings).toHaveLength(1); - expect(warnings[0]).toContain(`[adnbn:missing-isolation-css] Entrypoint "${entry}"`); - expect(warnings[0]).toContain("?isolation"); - } - }); - - test.each([ - undefined, - {}, - {isolation: {type: "none"}}, - {isolation: {type: "iframe", page: "panel"}}, - {isolation: {type: "iframe", src: "https://example.com/panel"}}, - ])("does not warn for unrelated entries or embedded documents: %j", options => { - expect(validateContentStyles("entry", options, {document: ["host.css"], isolated: []})).toEqual([]); - }); - - test("allows entries without CSS and mixed document/isolated styles", () => { - for (const document of [[], ["host.css"]]) { - expect( - validateContentStyles("panel", {isolation: {type: "shadow"}}, {document, isolated: ["ui.css"]}) - ).toEqual([]); - } - expect(validateContentStyles("panel", {isolation: {type: "shadow"}}, {document: [], isolated: []})).toEqual([]); - }); - - test.each([{page: "panel"}, {src: "https://example.com/panel"}])( - "rejects isolated CSS on navigation: %j", - frame => { - expect(() => - validateContentStyles( - "frame", - {isolation: {type: "iframe", ...frame}}, - {document: [], isolated: ["ui.css"]} - ) - ).toThrow('Entrypoint "frame" uses ?isolation CSS with isolation.page/isolation.src'); - } - ); -}); - describe("createPageAccessRequirements", () => { test("resolves page aliases from supplied filenames without weakening matches using exclusions", () => { const entries = new Map([ @@ -60,7 +17,9 @@ describe("createPageAccessRequirements", () => { ["source.content", {isolation: {type: "iframe", src: "https://example.com/panel"}}], ["shadow.content", {isolation: {type: "shadow"}}], ]); + const requirements = createPageAccessRequirements(entries, new Map([["panel", "views/custom-panel.html"]])); + expect(requirements).toEqual([ { resource: "views/custom-panel.html", diff --git a/src/cli/plugins/content/bundler.ts b/src/cli/plugins/content/bundler.ts index 05185dab..9d95b31c 100644 --- a/src/cli/plugins/content/bundler.ts +++ b/src/cli/plugins/content/bundler.ts @@ -1,31 +1,8 @@ import type {ResourceAccessPluginRequirement} from "@cli/bundler/plugins/resource-access"; -import type {IsolatedStylesPluginFiles} from "@cli/bundler/plugins/isolated-styles"; -import {isContentScriptFrameNavigation} from "@shared/content"; -import {hasIsolatedTarget} from "./utils"; import {ContentScriptMatches, ContentScriptWorld, type ContentScriptEntrypointOptions} from "@typing/content"; import {EntrypointType} from "@typing/entrypoint"; -/** Reject unavailable destinations and warn about likely missing query flags without changing CSS routing. */ -export const validateContentStyles = ( - entry: string, - options: ContentScriptEntrypointOptions | undefined, - files: IsolatedStylesPluginFiles -): readonly string[] => { - if (!options) return []; - if (files.isolated.length > 0 && isContentScriptFrameNavigation(options.isolation)) { - throw new Error( - `Entrypoint "${entry}" uses ?isolation CSS with isolation.page/isolation.src, but has no local render target. Import these styles in the embedded page instead.` - ); - } - if (hasIsolatedTarget(options) && files.document.length > 0 && files.isolated.length === 0) { - return [ - `[adnbn:missing-isolation-css] Entrypoint "${entry}" uses isolation: "${options.isolation?.type}", but has no CSS marked with ?isolation. Imported CSS will be delivered to the page document. If these styles belong to the isolated UI, import them with ?isolation. Document-only styles are valid; this warning can be suppressed with bundler ignoreWarnings.`, - ]; - } - return []; -}; - export const createPageAccessRequirements = ( entries: ReadonlyMap, pages: ReadonlyMap @@ -34,9 +11,13 @@ export const createPageAccessRequirements = ( for (const [entry, options] of entries) { const alias = options.isolation?.page; - if (alias === undefined) continue; + + if (alias === undefined) { + continue; + } const resource = pages.get(alias); + if (!resource) { throw new Error(`Content entrypoint "${entry}" references unknown page "${alias}"`); } diff --git a/src/cli/plugins/content/index.ts b/src/cli/plugins/content/index.ts index 6f0cca6d..9a8ab74d 100644 --- a/src/cli/plugins/content/index.ts +++ b/src/cli/plugins/content/index.ts @@ -5,7 +5,7 @@ import Content from "./Content"; import Relay from "./Relay"; import RelayDeclaration from "./RelayDeclaration"; import {hasIsolatedTarget} from "./utils"; -import {createPageAccessRequirements, getContentChunkName, validateContentStyles} from "./bundler"; +import {createPageAccessRequirements, getContentChunkName} from "./bundler"; import {createRelayModule, RelayModuleName} from "./relay-module"; import {definePlugin} from "@main/plugin"; @@ -54,6 +54,7 @@ export default definePlugin(() => { const getModules = async () => ({ [RelayModuleName]: createRelayModule(await relayProvider.getOptionsMap()), }); + const modulePlugin = new GenerateModulePlugin(await getModules()); if (config.command === Command.Watch) { @@ -74,6 +75,7 @@ export default definePlugin(() => { const views = await new PageFinder(config).views(); const pages = new Map(Array.from(views.values(), view => [view.alias, view.filename])); + return createPageAccessRequirements(entryOptionsByName, pages); }, }), @@ -88,6 +90,7 @@ export default definePlugin(() => { } const entries = await contentManager.entries(); + const getEntryWorld = (name: string): ContentScriptWorld => { const options = entryOptionsByName.get(name); @@ -167,9 +170,9 @@ export default definePlugin(() => { property: ContentScriptStylesRuntimeProperty, test: entry => { const options = entryOptionsByName.get(entry); + return options !== undefined && hasIsolatedTarget(options); }, - validate: (entry, files) => validateContentStyles(entry, entryOptionsByName.get(entry), files), }), ...basePlugins, ], diff --git a/src/cli/plugins/locale/Locale.test.ts b/src/cli/plugins/locale/Locale.test.ts index 386dfbbc..37d0a268 100644 --- a/src/cli/plugins/locale/Locale.test.ts +++ b/src/cli/plugins/locale/Locale.test.ts @@ -210,7 +210,7 @@ describe("locale JSON completion", () => { }); test.each([Browser.Chrome, Browser.Firefox])( - "catalogue matches every completed JSON message for %s", + "catalogue matches completed JSON messages without native escapes for %s", async browser => { const locale = makeLayeredLocale({browser}); const [json, catalogue] = await Promise.all([locale.json(), locale.catalogue()]); @@ -223,6 +223,32 @@ describe("locale JSON completion", () => { } ); + test.each([Browser.Chrome, Browser.Firefox])("escapes dollars only in native JSON for %s", async browser => { + const locale = makeLocale("dollars", {browser}); + const catalogue = await locale.catalogue(); + const json = await locale.json(); + + for (const lang of [Language.English, Language.French]) { + expect(catalogue[lang]).toMatchObject({ + price: "${{value}}/week", + spaced: "$ {{value}}/week", + dollars: "$$ and $$$", + named: "$USD$", + }); + expect(messages(json, lang)).toMatchObject({ + price: {message: "$${{value}}/week"}, + spaced: {message: "$$ {{value}}/week"}, + dollars: {message: "$$$ and $$$$"}, + named: {message: "$text$", placeholders: {text: {content: "$$USD$$"}}}, + }); + } + + expect(await locale.catalogue()).toEqual(catalogue); + expect(await locale.json()).toEqual(json); + expect((await locale.builders()).get(Language.English)!.get().get("price")).toBe("${{value}}/week"); + expect((await locale.structure()).price).toEqual({plural: false, substitutions: ["value"]}); + }); + test("validates the catalogue directly and recovers after clearing invalid sources", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-locale-catalogue-")); temporaryDirectories.push(root); diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index 5189dac6..16784c81 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -3,7 +3,7 @@ import {LocaleFinder} from "@cli/entrypoint"; import type {GenerateJsonPluginData, GenerateModulePluginDependencies} from "@cli/bundler"; import {getSourcePath, getSharedPath, getAppPath, getAppSourcePath} from "@cli/resolvers/path"; -import {flattenLocaleMessages, getLocaleFilename} from "@shared/locale"; +import {escapeLocaleMessages, flattenLocaleMessages, getLocaleFilename} from "@shared/locale"; import type {Language, LocaleCatalogue, LocaleMessages} from "@typing/locale"; @@ -26,7 +26,10 @@ export default class Locale extends LocaleFinder { public async json(): Promise { return Object.fromEntries( - [...(await this.messages())].map(([lang, messages]) => [getLocaleFilename(lang), messages]) + [...(await this.messages())].map(([lang, messages]) => [ + getLocaleFilename(lang), + escapeLocaleMessages(messages), + ]) ); } diff --git a/src/cli/plugins/locale/tests/fixtures/completion/dollars/src/locales/en.yaml b/src/cli/plugins/locale/tests/fixtures/completion/dollars/src/locales/en.yaml new file mode 100644 index 00000000..33f65ee6 --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/dollars/src/locales/en.yaml @@ -0,0 +1,5 @@ +title: Dollar test +price: "${{value}}/week" +spaced: "$ {{value}}/week" +dollars: "$$ and $$$" +named: "$USD$" diff --git a/src/cli/plugins/locale/tests/fixtures/completion/dollars/src/locales/fr.yaml b/src/cli/plugins/locale/tests/fixtures/completion/dollars/src/locales/fr.yaml new file mode 100644 index 00000000..5280910e --- /dev/null +++ b/src/cli/plugins/locale/tests/fixtures/completion/dollars/src/locales/fr.yaml @@ -0,0 +1 @@ +title: Test du dollar diff --git a/src/cli/plugins/style/index.ts b/src/cli/plugins/style/index.ts index a47a4886..a330f1eb 100644 --- a/src/cli/plugins/style/index.ts +++ b/src/cli/plugins/style/index.ts @@ -4,8 +4,7 @@ import fs from "fs"; import {Configuration as RspackConfig, CssExtractRspackPlugin, RuleSetUse, RuleSetUseItem} from "@rspack/core"; import {mergeStyleSources} from "./utils"; -import type {StylePluginOptions} from "./types"; -import {IsolatedStylesLayer} from "@cli/bundler/layers"; +import {DocumentStylesLayer, DefaultStylesLayer} from "@cli/bundler/layers"; import {definePlugin} from "@main/plugin"; @@ -16,9 +15,9 @@ import {toPosix} from "@cli/utils/path"; import {ReadonlyConfig} from "@typing/config"; // CssExtract also identifies dependencies by loader request, not just by layer. -// Keep isolated requests distinct from ordinary CSS and from each other's options. -const IsolatedAsIsLoaderIdent = "adnbn-isolated-asis"; -const IsolatedModulesLoaderIdent = "adnbn-isolated-modules"; +// Keep default and document styles distinct even when their contents are identical. +const DefaultAsIsLoaderIdent = "adnbn-default-asis"; +const DefaultModulesLoaderIdent = "adnbn-default-modules"; // prettier-ignore const styleMergerLoader = @@ -59,7 +58,7 @@ const styleMergerLoader = } }; -export default definePlugin(({isolationIssuerLayer}: StylePluginOptions = {}) => { +export default definePlugin(() => { return { name: "adnbn:styles", bundler: ({config}) => { @@ -83,19 +82,19 @@ export default definePlugin(({isolationIssuerLayer}: StylePluginOptions = {}) => return rules; }; - const createStyleRules = (isolated = false) => [ + const createStyleRules = (defaultStyles = false) => [ { resourceQuery: /[?&]asis(?:[=&]|$)/, use: createSassRuleSet({ loader: "css-loader", - ...(isolated ? {ident: IsolatedAsIsLoaderIdent} : {}), + ...(defaultStyles ? {ident: DefaultAsIsLoaderIdent} : {}), options: {esModule: true, modules: false}, }), }, { use: createSassRuleSet({ loader: "css-loader", - ...(isolated ? {ident: IsolatedModulesLoaderIdent} : {}), + ...(defaultStyles ? {ident: DefaultModulesLoaderIdent} : {}), options: { esModule: true, modules: { @@ -119,37 +118,24 @@ export default definePlugin(({isolationIssuerLayer}: StylePluginOptions = {}) => chunkFilename: filename, }), ], - optimization: { - splitChunks: { - cacheGroups: { - adnbnIsolatedStyles: { - type: "css/mini-extract", - layer: IsolatedStylesLayer, - chunks: "all", - enforce: true, - name: false, - priority: 100, - }, - }, - }, - }, module: { rules: [ { test: /\.(scss|css)$/, type: "javascript/auto", oneOf: [ - ...(isolationIssuerLayer === undefined - ? [] - : [ - { - issuerLayer: isolationIssuerLayer, - resourceQuery: /[?&]isolation(?:[=&]|$)/, - layer: IsolatedStylesLayer, - oneOf: createStyleRules(true), - }, - ]), - {oneOf: createStyleRules()}, + { + resourceQuery: /[?&]unisolated(?:[=&]|$)/, + layer: DocumentStylesLayer, + oneOf: createStyleRules(), + }, + { + // Nested CSS imports preserve an explicit document destination. + issuerLayer: DocumentStylesLayer, + layer: DocumentStylesLayer, + oneOf: createStyleRules(), + }, + {layer: DefaultStylesLayer, oneOf: createStyleRules(true)}, ], }, ], diff --git a/src/cli/plugins/style/types.ts b/src/cli/plugins/style/types.ts deleted file mode 100644 index ad5a915b..00000000 --- a/src/cli/plugins/style/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {RuleSetCondition} from "@rspack/core"; - -export interface StylePluginOptions { - /** Issuer layers where ?isolation selects a separate CSS destination. Omit to use ordinary CSS rules. */ - isolationIssuerLayer?: RuleSetCondition; -} diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 9732b89f..6a1d1de0 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -31,7 +31,6 @@ import { } from "../plugins"; import {fromRootPath, getAppPath, getAppSourcePath, getConfigFile} from "../resolvers/path"; -import {isContentLayer} from "@cli/bundler/layers"; import type {Config, OptionalConfig, ReadonlyConfig, UserConfig} from "@typing/config"; import {Command, Mode, Workspace} from "@typing/app"; @@ -364,7 +363,7 @@ export default async (config: OptionalConfig): Promise => { pluginReact(), pluginIcon(), pluginAsset(), - pluginStyle({isolationIssuerLayer: isContentLayer}), + pluginStyle(), pluginLocale(), pluginMeta(), pluginContent(), diff --git a/src/entry/content/README.md b/src/entry/content/README.md index dc72b8d7..b0b163d5 100644 --- a/src/entry/content/README.md +++ b/src/entry/content/README.md @@ -10,31 +10,87 @@ import {defineContentScript} from "adnbn"; import {createMutationObserverStrategy} from "adnbn/content"; export default defineContentScript({ - anchor: "article", + anchor: "article.ready", watch: createMutationObserverStrategy({ - attributes: false, - characterData: false, + attributes: true, + attributeFilter: ["class"], }), render: () => "Hello", }); ``` -`createMutationObserverStrategy(options?)` creates a debounced DOM-mutation strategy with configurable -`MutationObserverInit` options. `createAwaitFirstStrategy(options?)` provides the strategy used while -waiting for the first content nodes. Creating a strategy does not start observing; the lifecycle -starts it when processing content and invokes its returned unsubscribe function during cleanup. +`watch: true` keeps observing added and removed nodes throughout the document body (`childList` and +`subtree`). Attribute and character-data changes are not watched by default. If an existing element +becomes an anchor through a class or attribute change, opt in with `createMutationObserverStrategy` +and preferably an `attributeFilter`, as above. Text-dependent XPath can opt in to `characterData`. + +`createMutationObserverStrategy(options?)` accepts `MutationObserverInit` options and batches relevant +mutations in a fixed 200 ms window. Further mutations do not postpone that window, so a continuously +changing page still gets processed. While an asynchronous discovery/preparation pass is running, +requests are merged into a pending pass rather than queued individually. The strategy's `update()` +returns the same Promise for requests within an active cycle, completing after all requested repeat +passes. Failed watch cycles are reported once, without accumulating one error handler per notification. +Destroying/rebuilding the builder invalidates the old cycle and cancels its observer and timer; +already-running user promises are not aborted, but their obsolete results are ignored. An externally +retained, unresolved `prepare()` Promise can keep its in-flight builder and anchor reachable until it +settles; `destroy()` does not cancel that user operation. + +Built-in strategies ignore changes inside UI containers attached by the framework's mount handler. +This includes containers created by a user factory, but not already-connected elements returned by +that factory. A container wrapping its own anchor is never excluded. Removing/moving the host or a +batch containing external changes still triggers processing. This is a watch rule, not merely a +performance optimization: internal UI changes do not trigger anchor discovery, even when an external +selector such as `:has()` would match differently. Discovery itself still uses the full anchor +query, including CSS and XPath; it is not restricted to added subtrees. + +`context.owns(target)` answers whether a DOM node belongs to UI attached by this context. It is a +required method of `ContentScriptContext`, so custom strategies and wrapped contexts use the same +query without knowing lifecycle classes. It includes the host itself and descendants across open or +closed ShadowRoots. Traversal stops at a document: it does not cross from iframe contents to the outer +host (the iframe element itself is included). Already-connected site containers are excluded, and +whether a registered container wraps any of its anchors is checked at query time. Unmount releases +membership before user cleanup. Tracking is local to each context, with no global registry or extra +properties on `ContentScriptNode`. + +A custom strategy can reuse this query to avoid reacting to its own UI: + +```ts +import type {ContentScriptWatchStrategy} from "adnbn/content"; + +const watch: ContentScriptWatchStrategy = (update, context) => { + const observer = new MutationObserver(records => { + if (records.some(record => !context.owns(record.target))) { + void update(); + } + }); + + observer.observe(document.body ?? document.documentElement, {childList: true, subtree: true}); + + return () => observer.disconnect(); +}; +``` + +This example requests processing immediately for each relevant batch; the built-in strategy also +provides the 200 ms window described above. + +When `watch` is omitted, `createAwaitFirstStrategy(options?)` waits for the first content nodes and +then stops DOM observation. Creating either strategy does not start observing; the lifecycle starts +it when processing content and invokes its unsubscribe function during cleanup. Location tracking +remains separate: it checks URL changes and mounts existing nodes, even after await-first stops. `adnbn/content` also exports `ContentScriptEvent` and the `ContentScriptWatchStrategy`, `ContentScriptContext`, `ContentScriptEventCallback`, and `ContentScriptNode` types. A custom strategy receives `(update, context)` and returns an unsubscribe function. Use `context.watch(callback)` for lifecycle events; its returned function removes that subscription, and `context.unwatch()` removes all -context subscriptions. `context.mount()` and `context.unmount()` run synchronously, including their -lifecycle events. Await asynchronous discovery and preparation through `builder.build()` or the -watch strategy's `update()` callback. +context subscriptions. `context.mount()` and `context.unmount()` are synchronous commands. +For isolated UI, Mount is emitted later, after CSS readiness and acceptance by the renderer adapter; +Unmount is emitted synchronously. Await asynchronous discovery and preparation through `builder.build()` +or the watch strategy's `update()` callback; these do not wait for isolated UI to render. The public `src/content/index.ts` entrypoint explicitly exports these tools from their runtime owners. Definition helpers live in `src/main/content.ts`; normalization and mounting helpers are internal. Runtime code imports implementations directly, without depending on the public `adnbn/content` facade. +Watch factories depend only on the public context contract and import no lifecycle implementations. The separate `adnbn/entry/content` entrypoint supplies the builder, startup function, and definition resolver required by generated modules. @@ -42,14 +98,21 @@ resolver required by generated modules. `lifecycle/Builder.ts` coordinates main execution, watching, context cleanup, and node events. `lifecycle/MountBuilder.ts` is a concrete builder that composes mounting and isolation without a UI -renderer. React and Vanilla extend it with rendering. Related implementations and their tests live together: +renderer. Its `createNode(anchor, result)` selects the node for the preparation result: `false` or +headless rendering creates a tracked node without UI; otherwise it composes the `createMountNode`, +`createIsolation` and `createRenderer` stages. React and Vanilla supply the renderer stage. +Related implementations and their tests live together: - `lifecycle/IsolationSetup.ts`: prepares current props, invokes boundary/target handlers, and protects user cleanup. +- `lifecycle/types.ts`: node composition, processing, renderer readiness, and mount-completion contracts. - `lifecycle/nodes`: host mounting, node decorators, ShadowRoot/iframe targets, and the styles-runtime helper. - `lifecycle/markers`: anchor marking and lookup strategies. -- `lifecycle/context`: the node collection, lifecycle operations, and event subscriptions. +- `lifecycle/context`: the node collection, lifecycle operations, event subscriptions, and per-context + `ContainerRegistry`. `MountNode` registers before invoking the mount handler and releases only its + own registration on failure or unmount; decorators and renderer adapters do not carry this state. - `resolvers`: shared option handlers and definition merging, without framework detection or rendering. -- `adapters/react` and `adapters/vanilla`: default-export interpretation, render normalization, and UI rendering. +- `adapters/react` and `adapters/vanilla`: `definition.ts` interprets default exports, `Builder.ts` + normalizes render handlers, and `Node.ts` renders UI. Tests live beside each implementation. - `index.ts`: the common runtime entrypoint, exporting `MountBuilder` as `Builder` and its startup resolver. Pure structural validation and the frame-navigation predicate live in `src/shared/content/isolation.ts`. @@ -70,9 +133,10 @@ For React UI, pass a React element or component function. Component functions ar and may use hooks. Vanilla renders DOM elements, nonempty strings, and numbers. `mergeDefinition` combines exports using the selected resolver's interpretation of default values. The common `resolveDefinition` accepts configuration without recognizing framework -components; adapters provide their own definition resolvers. The common builder accepts absent rendering and literal `render: true`; UI rendering requires an adapter. Vanilla keeps its value check and -synchronous handler normalization together in `adapters/vanilla/resolvers/render.ts`, used internally by the -Vanilla builder. +components; adapters provide their own definition resolvers. The common builder accepts absent rendering +and literal `render: true`; UI rendering requires an adapter. Each adapter prepares synchronous render +handlers in its builder's `resolveRender()` method. Vanilla shares its value check between the builder +and the definition resolver through `adapters/vanilla/utils.ts`. The CLI `ContentParser.ts` and its test live beside the other parsers in `src/cli/entrypoint/parser`. Content fixtures live in `parser/tests/fixtures/content`. The helper in @@ -81,12 +145,21 @@ rejects known default render exports with frame navigation, including explicit e The common runtime also rejects an explicit `render` property with frame navigation. Relay interprets its default export as transport initialization. -The common lifecycle wraps each complete node in `EventNode`, including any adapter renderer, before -adding it to the context. Mount/unmount events follow the underlying node operations. Without render, +The common lifecycle wraps each complete node in `EventNode`, including any adapter renderer, and +connects the renderer's `setErrorHandler` before adding the node to the context. This ordering also +covers an Add listener that immediately calls `mount()`. `EventNode` only receives the narrower mount +notifier contract; it never handles renderer failures. Mount/unmount events follow the underlying node +operations. Without render, `main` runs and context cleanup is available. Anchor processing starts for rendering, `prepare`, headless tracking, or page/src navigation. `FrameNode` owns iframe creation, navigation, and child-document recovery. +`build()` finishes cleanup of the previous run before invoking `main(context, options)`. It awaits +`main` before resolving the marker and processing anchors, so main can subscribe to node events +without waiting for an asynchronous marker factory. Entries with only `main` do not invoke the +marker factory. Cancellation during either initialization step prevents subsequent processing +and watcher startup. A marker failure still rejects the build, but main has already run at that point. + When adding a runtime adapter, implement its definition and render resolvers and expose the definition resolver from that adapter's `index.ts`. Extend filename/build support and parser metadata interpretation as required by the framework. Runtime adapters are selected individually; do not aggregate their @@ -125,6 +198,9 @@ policy applies to later passes. Use `watch: true` to keep processing new anchors stops watching once it has tracked nodes. A failed anchor is not added as a headless result. Initialization errors, such as a rejected marker factory or `main`, reject the build. React component errors during React's scheduled rendering follow React's error handling. +An isolated renderer runs after CSS readiness, outside the initial processing pass. A failure in +that deferred mount is reported separately and removes the failed node; other anchors continue. +CSS loading failures instead retain the empty target so a later `context.mount()` can retry. ```tsx title="src/product.content.tsx" import React from "react"; @@ -165,10 +241,18 @@ instances. It works with both adapters and Relay. Use it for parsing existing no Destroying the builder or removing the anchor invalidates pending results. A new target on remount or iframe document recovery receives fresh props and reruns rendering with the same prepared data. -Mounting an unchanged target preserves its UI and React state. `RenderNode` mounts DOM and invokes the -renderer synchronously; adapter nodes own UI insertion and disposal. `EventNode` emits Mount before -`mount()` returns, after the adapter accepts the render value. React schedules its component rendering -and effects independently; Mount does not wait for a React commit. A render callback that unmounts or +Mounting an unchanged target preserves its UI and React state. `RenderNode` mounts DOM synchronously. +Without isolation it also invokes the renderer synchronously. For Shadow and blank iframe targets, +it waits for the styles runtime's `ready(root)` before invoking the renderer. Repeated mounts of the +same pending target share one operation. All node and context `mount()` / `unmount()` methods remain +synchronous. A node's `mount()` returns `false` while its renderer is waiting; it does not return a Promise. +CSS readiness continues rendering directly without calling `mount()` or the user's mount handler again. +The renderer notifies `EventNode` once after the adapter accepts the render value. Observe Mount through +the context to track completion; returning from `mount()` does not guarantee that isolated UI is ready. +The readiness function and completion notifier are internal lifecycle contracts, not public node methods. +`EventNode` only dispatches events; the builder handles deferred renderer failures. +React schedules its component rendering and effects independently; Mount does not wait for a React commit. +A render callback that unmounts or replaces its own target cannot subsequently insert UI into the discarded target or emit a stale Mount. ## Isolation @@ -299,7 +383,7 @@ existing anchors, containers, mount/append placement, and context methods remain ```tsx title="src/panel.content/index.tsx" import {ContentScriptIsolation, defineContentScriptAppend} from "adnbn"; -import "./panel.css?isolation"; +import "./panel.scss"; import Panel from "./Panel"; export default defineContentScriptAppend({ @@ -328,7 +412,7 @@ Browser defaults and page styles apply unless you override them. CSS sizes use t values; automatic content-based iframe sizing is not provided. The blank iframe has no `src` attribute. The framework creates the host and target, so a custom `container` factory is optional. -Import UI styles with `?isolation` and declare fonts in CSS as described below. Relay supports the +Import UI styles normally and declare fonts in SCSS as described below. Relay supports the same isolation and frame variants; its RPC transport and all-frame addressing remain independent of UI isolation. @@ -338,7 +422,7 @@ Set `mode` inside an isolation object with `type: Shadow`, in content scripts or ```tsx title="src/panel.content/index.tsx" import {ContentScriptIsolation, ContentScriptShadowMode, defineContentScriptAppend} from "adnbn"; -import "./panel.css?isolation"; +import "./panel.scss"; import Panel from "./Panel"; export default defineContentScriptAppend({ @@ -441,69 +525,78 @@ if it must survive. For page/src, reload remains ordinary browser navigation beh ## Styles and fonts Shadow and all iframe entries bypass `concatContentScripts`. They support `commonChunks`. -Plain CSS/SCSS imports keep document delivery: initial CSS goes into `content_scripts.css`; lazy CSS -loads into the page only when its `import()` runs. UI isolation does not change plain imports. +For Shadow and blank iframe content/Relay entries, ordinary CSS/SCSS imports go to the isolated +render target. This includes styles imported by child components, shared components, libraries and +lazy modules. Mark only host-page styles with `?unisolated`: ```ts title="src/panel.content/index.ts" -import "./host.css?asis"; -import styles from "./panel.module.css?isolation"; +import "./host.scss?unisolated"; +import styles from "./panel.module.scss"; export {}; ``` -`?isolation` sends CSS to ShadowRoot or the blank iframe document. Combine it with `?asis` as -`?isolation&asis` to disable CSS Modules; the two flags have independent roles. Local CSS imports and -Sass dependencies inherit their stylesheet's destination. A separate CSS import in JavaScript chooses -its own destination. +The query controls delivery, not CSS Modules. Module exports and class names retain their normal +behavior. CSS imports and Sass dependencies inherit their stylesheet's destination. To deliver a +stylesheet outside the UI, put the query on its JavaScript/TypeScript import; Sass `@use`/`@forward` +compile into their parent stylesheet and do not create another delivery destination. Initial isolated CSS is excluded from `content_scripts.css` and exposed through WAR. Each root or -iframe head receives its own links in asset-map order. A shared CSS file can remain in an ordinary -consumer's manifest and also be linked by an isolated consumer. `getEntrypointAssets()` includes every -CSS file in `initial.css` / `async.css`. The isolated styles runtime owns CSS routing; the asset map does not expose delivery-specific subsets. - -With `isolation: None`, marked CSS loads normally into the page. Outside content and Relay, including -popup and page entries, `?isolation` has no routing effect and adds no isolated-style runtime. -`isolation.page`/`isolation.src` entries have no local render target: importing `?isolation` CSS there is a build -error. Import the styles in the embedded page instead. That page's own CSS behaves normally. - -The build reports `[adnbn:missing-isolation-css]` when a Shadow or blank-iframe content/Relay entry -has CSS dependencies but none are marked `?isolation`. The check includes initial, shared and lazy CSS; -it reports once per entry per compilation and is refreshed in watch mode. It does not change asset -delivery. Entries without CSS, non-isolated entries, embedded pages and unrelated entrypoints are -not warned. Document-only CSS is legitimate, for example when only a font is imported and the UI uses -inline styles. To silence this diagnostic intentionally, use the existing bundler warning filter: - -```ts title="adnbn.config.ts" -import {defineConfig} from "adnbn"; - -export default defineConfig({ - bundler: { - ignoreWarnings: [/\[adnbn:missing-isolation-css\]/], - }, -}); -``` - -This is a heuristic: a single correctly marked dependency prevents the warning even if another UI -stylesheet is missing its query. Initial document CSS still goes into the manifest; lazy document -CSS still waits for its import. - -Rspack separates CSS destinations before emitting assets. Preserve the `adnbnIsolatedStyles` cache -group if customizing `splitChunks`: a chunk mixing destinations fails the build rather than injecting -styles into the wrong document. This partition is necessary even with `commonChunks: false`. - -Rendering starts immediately, so briefly unstyled UI is possible. Lazy CSS is requested with +iframe head receives its own links in dependency order. Initial `?unisolated` CSS goes into the +content manifest. Lazy styles of both destinations still wait for `import()`. + +A component shared between popup and content needs no special imports: the popup gets ordinary +page CSS, while a Shadow/blank-iframe consumer gets isolated CSS. Shared sources can be emitted +separately by destination. If identical CSS resolves to one contenthash filename, that file can +appear both in the content manifest and in isolated delivery. `getEntrypointAssets()` still includes +the complete inventory in `initial.css` / `async.css`, without delivery-specific fields. + +With `isolation: None`, styles load into the host document. Outside content and Relay, including +popup and page entries, `?unisolated` adds no behavior or isolated-style runtime. +`isolation.page`/`isolation.src` entries do not render locally: their imports style the host document; +import the embedded UI's styles in the embedded page itself. + +The `adnbnDocumentStyles` cache group extracts `?unisolated` CSS only from chunks reachable by +Shadow/blank-iframe entries, including lazy chunks. Default UI CSS stays in its original chunk. +Ordinary pages, popup/options, content and Relay entries keep both categories together unless they +share a physical chunk with an isolated consumer. No extra category priority is imposed on those +unsplit files; Rspack's normal CSS ordering applies. This does not enable general common-CSS extraction. + +A lazy chunk shared by ordinary and isolated entries must be split for both consumers. In the +ordinary document, the two files follow chunk order, which can differ from source import order. +On the tested Rspack version, document CSS precedes UI CSS for both source orders. This is a shared +physical-chunk constraint, not a query-priority contract. In Shadow/iframe the destinations differ. + +Preserve `adnbnDocumentStyles` when customizing `splitChunks`: a mixed-destination chunk reachable +by an isolated entry fails the build instead of injecting CSS into the wrong document. Mixed CSS +is legal for ordinary-only entries. The required partition also applies with `commonChunks: false`. +Initial chunks (including extracted chunks) use `cssFilename`; async chunks use `cssChunkFilename` +when configuring the CSS extraction plugin directly. + +Shadow and blank iframe renderers wait for all initial CSS and lazy CSS already requested when +the target starts waiting. The host, boundary and empty target exist during loading; no component +or React effects run before readiness. Open and closed Shadow roots follow the same lifecycle. +Unmount, remount and iframe recovery invalidate pending rendering and release old link waits and timers. +Each new target waits for its own links, even when the browser has cached the stylesheet files. + +Lazy CSS is requested with `import()`; mixed imports wait for both document CSS and targets active at that request's start. Late targets receive initial and already requested lazy CSS. Imports before the first target do not wait for future UI. Failed -or timed-out lazy links reject the import and permit retry; timeout follows `output.chunkLoadTimeout`. -Initial CSS errors identify the entrypoint and URL but do not remove UI. Styles are delivered through +or timed-out lazy links reject the import and permit retry. Initial readiness failures identify +the entrypoint and URL and leave the target empty, without emitting Mount. A later `context.mount()` +retries missing styles without duplicating successful links or changing their cascade order. +The timeout follows `output.chunkLoadTimeout` (Rspack's default is 120 seconds); no separate UI timeout +or automatic unstyled fallback is applied. Stylesheet readiness does not wait for fonts or images. +Navigation iframes (`page`/`src`) and non-isolated rendering keep their existing behavior. +Styles are delivered through external stylesheet links. Registries belong to each entry runtime, not `window`; no carrier, JSON map or background bundle is injected into other entries. -For Shadow, declare `@font-face` in ordinary document CSS, then use that family inside `?isolation` -CSS. `@font-face` inside a shadow stylesheet does not reliably register the face. Use a unique family +For Shadow, declare `@font-face` in `fonts.scss?unisolated`, then use that family in ordinary UI +SCSS. `@font-face` inside a shadow stylesheet does not reliably register the face. Use a unique family name: the document declaration is visible to the host page and outlives UI unmount. -```css title="src/panel.content/host.css" +```scss title="src/panel.content/fonts.scss" @font-face { font-family: "AdnbnPanelInter"; src: url("./panel.woff2?browser") format("woff2"); @@ -520,7 +613,7 @@ stylesheets and specialized `?react`/`?raw` imports retain their own loaders. Th is interpreted in CSS, not JavaScript: use the browser `getUrl()` helper for ordinary JS asset imports rather than treating that token as a ready runtime URL. User asset names and hashes remain intact. -For a blank iframe, put `@font-face` directly in its `?isolation` CSS and use a local relative font URL. +For a blank iframe, put `@font-face` directly in its ordinary SCSS and use a local relative font URL. It belongs to that iframe document; a host-page font declaration does not register it there. If both destinations need the face, import a small shared font stylesheet from both destination stylesheets. After document recovery, reconnecting the iframe CSS restores its font declarations too. diff --git a/src/entry/content/adapters/react/Builder.test.ts b/src/entry/content/adapters/react/Builder.test.ts index 6b004335..82dce036 100644 --- a/src/entry/content/adapters/react/Builder.test.ts +++ b/src/entry/content/adapters/react/Builder.test.ts @@ -2,7 +2,8 @@ import {act} from "@testing-library/react"; import {createElement, useEffect, useState} from "react"; import Builder from "./Builder"; -import {resolveDefinition} from "./resolvers/definition"; +import {resolveDefinition} from "./definition"; +import {createMutationObserverStrategy} from "../../resolvers/watch"; import {ContentScriptEvent, type ContentScriptContext, type ContentScriptProps} from "@typing/content"; // Extend the shared random-ID mock for the builders' generated marker attribute. @@ -14,6 +15,50 @@ jest.mock("nanoid", () => ({ describe("React Builder", () => { afterEach(() => document.body.replaceChildren()); + test("React UI updates do not schedule discovery inside a container mounted by the framework", async () => { + jest.useFakeTimers(); + const anchor = document.createElement("article"); + document.body.append(anchor); + const discover = jest.fn(() => anchor); + const update = jest.fn(); + const observe = createMutationObserverStrategy(); + + const builder = new Builder({ + anchor: discover, + watch: (process, context) => + observe(() => { + update(); + + return process(); + }, context), + render: () => { + const [expanded, setExpanded] = useState(false); + + return createElement( + "button", + {onClick: () => setExpanded(value => !value)}, + expanded ? createElement("strong", null, "expanded") : "collapsed" + ); + }, + }); + + try { + await act(() => builder.build()); + + await act(async () => { + anchor.querySelector("button")!.click(); + }); + + await act(() => jest.advanceTimersByTimeAsync(201)); + expect(anchor.querySelector("strong")?.textContent).toBe("expanded"); + expect(update).not.toHaveBeenCalled(); + expect(discover).toHaveBeenCalledTimes(1); + } finally { + await act(() => builder.destroy()); + jest.useRealTimers(); + } + }); + test("React renders a default component with hooks and cleans up its effects", async () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); diff --git a/src/entry/content/adapters/react/Builder.ts b/src/entry/content/adapters/react/Builder.ts index dcdd4c7f..ceab113b 100644 --- a/src/entry/content/adapters/react/Builder.ts +++ b/src/entry/content/adapters/react/Builder.ts @@ -1,12 +1,16 @@ +import {createElement, isValidElement} from "react"; + +import ReactNode from "./Node"; + import MountBuilder from "../../lifecycle/MountBuilder"; -import RenderNode from "./Node"; -import {createRenderResolver} from "./resolvers/render"; +import type {ContentScriptRenderOptions} from "../../lifecycle/types"; import type { ContentScriptNode, ContentScriptIsolation, ContentScriptProps, ContentScriptRenderHandler, + ContentScriptRenderReactComponent, ContentScriptRenderValue, } from "@typing/content"; @@ -16,15 +20,28 @@ export default class Builder< > extends MountBuilder { protected resolveRender( render?: ContentScriptRenderValue | ContentScriptRenderHandler - ): ContentScriptRenderHandler | undefined { - return render === undefined ? undefined : createRenderResolver(render); + ): true | ContentScriptRenderHandler | undefined { + if (render === true || render === undefined) { + return render; + } + + return props => { + // Functions in React entrypoints are components; React owns their invocation and hooks. + const value = + typeof render === "function" + ? createElement(render as ContentScriptRenderReactComponent, props) + : render; + + return isValidElement(value) ? value : undefined; + }; } protected createRenderer( node: ContentScriptNode, render: ContentScriptRenderHandler, - props: () => ContentScriptProps - ): ContentScriptNode { - return new RenderNode(node, render, props); + props: () => ContentScriptProps, + options: ContentScriptRenderOptions + ): ReactNode { + return new ReactNode(node, render, props, options); } } diff --git a/src/entry/content/adapters/react/Node.test.ts b/src/entry/content/adapters/react/Node.test.ts index 3488133a..c31f8890 100644 --- a/src/entry/content/adapters/react/Node.test.ts +++ b/src/entry/content/adapters/react/Node.test.ts @@ -18,6 +18,38 @@ describe("ReactNode", () => { jest.mocked(createRoot).mockReturnValue({render, unmount} as never); }); + test("a failed React unmount releases the inner node and does not retain the root", () => { + const anchor = document.createElement("article"); + const target = document.createElement("section"); + document.body.append(anchor); + const node = {anchor, target, mount: jest.fn(), unmount: jest.fn()}; + const renderer = new ReactNode( + node, + () => createElement("span"), + () => ({ + anchor, + container: target, + target, + boundary: undefined, + data: undefined, + }) + ); + + try { + renderer.mount(); + unmount.mockImplementationOnce(() => { + throw new Error("React cleanup failed"); + }); + + expect(() => renderer.unmount()).toThrow("React cleanup failed"); + expect(node.unmount).toHaveBeenCalledTimes(1); + renderer.unmount(); + expect(unmount).toHaveBeenCalledTimes(1); + } finally { + anchor.remove(); + } + }); + test.each([undefined, ContentScriptShadowMode.Open, ContentScriptShadowMode.Closed])( "renders into the target while preserving the outer host in %s mode", mode => { diff --git a/src/entry/content/adapters/react/Node.ts b/src/entry/content/adapters/react/Node.ts index 2f5c76f6..1e97a6a6 100644 --- a/src/entry/content/adapters/react/Node.ts +++ b/src/entry/content/adapters/react/Node.ts @@ -20,7 +20,8 @@ export default class Node extends RenderNode { } protected clear(): void { - this.root?.unmount(); + const root = this.root; this.root = undefined; + root?.unmount(); } } diff --git a/src/entry/content/adapters/react/resolvers/definition.test.ts b/src/entry/content/adapters/react/definition.test.ts similarity index 100% rename from src/entry/content/adapters/react/resolvers/definition.test.ts rename to src/entry/content/adapters/react/definition.test.ts diff --git a/src/entry/content/adapters/react/resolvers/definition.ts b/src/entry/content/adapters/react/definition.ts similarity index 88% rename from src/entry/content/adapters/react/resolvers/definition.ts rename to src/entry/content/adapters/react/definition.ts index f35b9e9f..7bdb034e 100644 --- a/src/entry/content/adapters/react/resolvers/definition.ts +++ b/src/entry/content/adapters/react/definition.ts @@ -1,5 +1,5 @@ import {isValidElement} from "react"; -import {mergeDefinition} from "../../../resolvers/definition"; +import {mergeDefinition} from "../../resolvers/definition"; import type {ContentScriptDefinition} from "@typing/content"; diff --git a/src/entry/content/adapters/react/index.ts b/src/entry/content/adapters/react/index.ts index 5be31bac..803aed33 100644 --- a/src/entry/content/adapters/react/index.ts +++ b/src/entry/content/adapters/react/index.ts @@ -1,6 +1,6 @@ import Builder from "./Builder"; export {Builder}; -export {resolveDefinition} from "./resolvers/definition"; +export {resolveDefinition} from "./definition"; export default Builder.resolver(); diff --git a/src/entry/content/adapters/react/resolvers/render.ts b/src/entry/content/adapters/react/resolvers/render.ts deleted file mode 100644 index 4113f7bc..00000000 --- a/src/entry/content/adapters/react/resolvers/render.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {createElement, isValidElement} from "react"; - -import type { - ContentScriptRenderHandler, - ContentScriptRenderValue, - ContentScriptRenderReactComponent, -} from "@typing/content"; - -export const createRenderResolver = - ( - render?: ContentScriptRenderValue | ContentScriptRenderHandler - ): ContentScriptRenderHandler => - props => { - // Functions in React entrypoints are components; React owns their invocation and hooks. - const value = - typeof render === "function" - ? createElement(render as ContentScriptRenderReactComponent, props) - : render; - - return isValidElement(value) ? value : undefined; - }; diff --git a/src/entry/content/adapters/vanilla/Builder.test.ts b/src/entry/content/adapters/vanilla/Builder.test.ts index 05310cc8..1bb7ee0a 100644 --- a/src/entry/content/adapters/vanilla/Builder.test.ts +++ b/src/entry/content/adapters/vanilla/Builder.test.ts @@ -1,5 +1,6 @@ import Builder from "./Builder"; -import {resolveDefinition} from "./resolvers/definition"; +import {waitFor} from "@testing-library/react"; +import {resolveDefinition} from "./definition"; import {defineContentScript, defineContentScriptAppend} from "@main/content"; import { ContentScriptAppend, @@ -17,12 +18,92 @@ jest.mock("nanoid", () => ({ })); jest.mock("../../lifecycle/nodes/isolated-styles", () => ({ - getContentScriptStylesRuntime: () => ({add: jest.fn(), delete: jest.fn()}), + getContentScriptStylesRuntime: () => ({add: jest.fn(), ready: jest.fn(async () => undefined), delete: jest.fn()}), })); describe("Vanilla Builder", () => { afterEach(() => document.body.replaceChildren()); + test("destroy releases membership, markers and listeners despite a failing mount cleanup", async () => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const events = jest.fn(); + let fail = true; + const builder = new Builder({ + anchor, + render: () => "UI", + mount: (anchor, container) => { + anchor.append(container); + + return () => { + if (fail) { + fail = false; + + throw new Error("Cleanup failed"); + } + }; + }, + }); + + try { + await builder.build(); + const context = builder.getContext(); + const [node] = context.nodes; + const host = node.container!; + context.watch(events); + await expect(builder.destroy()).rejects.toThrow("Content script context cleanup failed"); + expect(context.owns(host)).toBe(false); + expect(host.isConnected).toBe(false); + expect(anchor.getAttributeNames()).toEqual([]); + events.mockClear(); + await builder.build(); + expect(context.nodes.size).toBe(1); + expect(events).not.toHaveBeenCalled(); + } finally { + await builder.destroy(); + } + }); + + test.each(["none", "shadow", "iframe"] as const)( + "an idle %s mount does not mutate the document", + async isolation => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const builder = new Builder({anchor, isolation, render: () => "ready"}); + const observer = new MutationObserver(() => {}); + + try { + await builder.build(); + const context = builder.getContext(); + const [node] = context.nodes; + await waitFor(() => expect(node.target?.textContent).toBe("ready")); + const host = node.container!; + const target = node.target!; + expect(context.owns(host)).toBe(true); + expect(context.owns(target)).toBe(isolation !== "iframe"); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + + context.mount(); + expect(observer.takeRecords().length).toBe(0); + observer.disconnect(); + context.unmount(); + expect(context.owns(host)).toBe(false); + expect(context.owns(target)).toBe(false); + context.mount(); + await waitFor(() => expect(node.target?.textContent).toBe("ready")); + expect(context.owns(node.container!)).toBe(true); + } finally { + observer.disconnect(); + await builder.destroy(); + } + } + ); + test("Vanilla invokes a default handler and preserves props, placement and cleanup", async () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); @@ -85,6 +166,52 @@ describe("Vanilla Builder", () => { } }); + test.each(["shadow", "iframe"] as const)( + "%s connects renderer failure handling before an Add listener can mount the node", + async isolation => { + const anchor = document.createElement("article"); + document.body.append(anchor); + const error = new Error("Deferred renderer failed"); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + const events: ContentScriptEvent[] = []; + const render = jest.fn(() => { + throw error; + }); + + const builder = new Builder( + defineContentScript({ + anchor, + isolation, + render, + main: context => { + context.watch((event, node) => { + events.push(event); + + if (event === ContentScriptEvent.Add) { + expect(node.mount()).toBe(false); + } + }); + }, + }) + ); + + try { + await builder.build(); + await waitFor(() => expect(report).toHaveBeenCalledTimes(1)); + expect(report.mock.calls[0][0]).toBeInstanceOf(AggregateError); + expect(report.mock.calls[0][0].errors).toEqual([error]); + expect(render).toHaveBeenCalledTimes(1); + expect(events.filter(event => event === ContentScriptEvent.Remove)).toHaveLength(1); + expect(events).not.toContain(ContentScriptEvent.Mount); + expect(builder.getContext().nodes.size).toBe(0); + expect(anchor.childElementCount).toBe(0); + } finally { + await builder.destroy(); + report.mockRestore(); + } + } + ); + test("Vanilla preserves the literal true used to track an anchor without a container", async () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); @@ -262,7 +389,9 @@ describe("Vanilla preparation and render props", () => { expect(main.mock.calls[0][1]).not.toHaveProperty("target"); expect(rendered[0]).toMatchObject({boundary: first.boundary, target: node.target}); expect(node.target?.outerHTML).toBe('Prepared'); - expect(boundaries).toEqual([undefined, first.boundary]); + await waitFor(() => expect(boundaries).toHaveLength(2)); + expect(boundaries[0]).toBeUndefined(); + expect(boundaries[1]).toBe(first.boundary); context.mount(); expect(calls).toHaveLength(1); context.unmount(); @@ -273,6 +402,7 @@ describe("Vanilla preparation and render props", () => { expect(calls[1].boundary).not.toBe(first.boundary); expect(calls[1].container).not.toBe(first.container); expect(calls[1].data).toBe(data); + await waitFor(() => expect(rendered).toHaveLength(2)); expect(rendered[1].boundary).toBe(node.boundary); expect(prepare).toHaveBeenCalledTimes(1); } finally { @@ -453,9 +583,9 @@ describe("Vanilla preparation and render props", () => { try { await expect(builder.build()).resolves.toBeUndefined(); - expect(report).toHaveBeenCalledTimes(1); + await waitFor(() => expect(report).toHaveBeenCalledTimes(stage === "render" ? 2 : 1)); expect(report).toHaveBeenLastCalledWith(expect.any(AggregateError)); - expect(report.mock.calls[0][0].errors).toEqual([error, error]); + expect(report.mock.calls.flatMap(([reported]) => reported.errors)).toEqual([error, error]); expect(builder.getContext().nodes.size).toBe(1); expect(initial.firstElementChild?.shadowRoot?.textContent).toBe("content"); @@ -471,9 +601,9 @@ describe("Vanilla preparation and render props", () => { expect(next.firstElementChild?.shadowRoot?.textContent).toBe("content"); expect(rejected.childElementCount).toBe(0); expect(builder.getContext().nodes.size).toBe(2); - expect(report).toHaveBeenCalledTimes(2); + expect(report).toHaveBeenCalledTimes(stage === "render" ? 3 : 2); expect(report).toHaveBeenLastCalledWith(expect.any(AggregateError)); - expect(report.mock.calls[1][0].errors).toEqual([error]); + expect(report.mock.calls.at(-1)![0].errors).toEqual([error]); rejected.remove(); const last = createAnchor(); @@ -727,6 +857,7 @@ test("boundary cleanup does not hide the original render failure", async () => { try { await builder.build(); expect(logged).toHaveBeenCalledWith("Content script boundary cleanup failed", cleanup); + await waitFor(() => expect(builder.getContext().nodes.size).toBe(0)); const aggregate = logged.mock.calls.find(([error]) => error instanceof AggregateError)?.[0] as AggregateError; expect(aggregate.errors).toEqual([original]); expect(builder.getContext().nodes.size).toBe(0); diff --git a/src/entry/content/adapters/vanilla/Builder.ts b/src/entry/content/adapters/vanilla/Builder.ts index dcdd4c7f..be54c1be 100644 --- a/src/entry/content/adapters/vanilla/Builder.ts +++ b/src/entry/content/adapters/vanilla/Builder.ts @@ -1,6 +1,7 @@ import MountBuilder from "../../lifecycle/MountBuilder"; -import RenderNode from "./Node"; -import {createRenderResolver} from "./resolvers/render"; +import VanillaNode from "./Node"; +import {isValidRenderValue} from "./utils"; +import type {ContentScriptRenderOptions} from "../../lifecycle/types"; import type { ContentScriptNode, @@ -16,15 +17,28 @@ export default class Builder< > extends MountBuilder { protected resolveRender( render?: ContentScriptRenderValue | ContentScriptRenderHandler - ): ContentScriptRenderHandler | undefined { - return render === undefined ? undefined : createRenderResolver(render); + ): true | ContentScriptRenderHandler | undefined { + if (render === true || render === undefined) { + return render; + } + + return props => { + const resolvedRender = typeof render === "function" ? render(props) : render; + + if (!isValidRenderValue(resolvedRender)) { + return; + } + + return resolvedRender; + }; } protected createRenderer( node: ContentScriptNode, render: ContentScriptRenderHandler, - props: () => ContentScriptProps - ): ContentScriptNode { - return new RenderNode(node, render, props); + props: () => ContentScriptProps, + options: ContentScriptRenderOptions + ): VanillaNode { + return new VanillaNode(node, render, props, options); } } diff --git a/src/entry/content/adapters/vanilla/resolvers/definition.test.ts b/src/entry/content/adapters/vanilla/definition.test.ts similarity index 100% rename from src/entry/content/adapters/vanilla/resolvers/definition.test.ts rename to src/entry/content/adapters/vanilla/definition.test.ts diff --git a/src/entry/content/adapters/vanilla/resolvers/definition.ts b/src/entry/content/adapters/vanilla/definition.ts similarity index 69% rename from src/entry/content/adapters/vanilla/resolvers/definition.ts rename to src/entry/content/adapters/vanilla/definition.ts index 80c8bc86..ff1d6cf6 100644 --- a/src/entry/content/adapters/vanilla/resolvers/definition.ts +++ b/src/entry/content/adapters/vanilla/definition.ts @@ -1,5 +1,5 @@ -import {mergeDefinition} from "../../../resolvers/definition"; -import {isValidRenderValue} from "./render"; +import {mergeDefinition} from "../../resolvers/definition"; +import {isValidRenderValue} from "./utils"; import type {ContentScriptDefinition} from "@typing/content"; diff --git a/src/entry/content/adapters/vanilla/index.ts b/src/entry/content/adapters/vanilla/index.ts index 5be31bac..803aed33 100644 --- a/src/entry/content/adapters/vanilla/index.ts +++ b/src/entry/content/adapters/vanilla/index.ts @@ -1,6 +1,6 @@ import Builder from "./Builder"; export {Builder}; -export {resolveDefinition} from "./resolvers/definition"; +export {resolveDefinition} from "./definition"; export default Builder.resolver(); diff --git a/src/entry/content/adapters/vanilla/resolvers/render.ts b/src/entry/content/adapters/vanilla/resolvers/render.ts deleted file mode 100644 index 1976f486..00000000 --- a/src/entry/content/adapters/vanilla/resolvers/render.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type {ContentScriptRenderHandler, ContentScriptRenderValue} from "@typing/content"; - -export const isValidRenderValue = (value: unknown): value is string | number | Element => { - return (typeof value === "string" && value.length > 0) || typeof value === "number" || value instanceof Element; -}; - -// prettier-ignore -export const createRenderResolver = - (render?: ContentScriptRenderValue | ContentScriptRenderHandler): ContentScriptRenderHandler => - props => { - const resolvedRender = typeof render === "function" ? render(props) : render; - - if (!isValidRenderValue(resolvedRender)) { - return; - } - - return resolvedRender; - }; diff --git a/src/entry/content/adapters/vanilla/utils.ts b/src/entry/content/adapters/vanilla/utils.ts new file mode 100644 index 00000000..022f9add --- /dev/null +++ b/src/entry/content/adapters/vanilla/utils.ts @@ -0,0 +1,3 @@ +export const isValidRenderValue = (value: unknown): value is string | number | Element => { + return (typeof value === "string" && value.length > 0) || typeof value === "number" || value instanceof Element; +}; diff --git a/src/entry/content/lifecycle/Builder.ts b/src/entry/content/lifecycle/Builder.ts index 5e59accd..e7bbb5d8 100644 --- a/src/entry/content/lifecycle/Builder.ts +++ b/src/entry/content/lifecycle/Builder.ts @@ -1,5 +1,4 @@ import {isContentScriptFrameNavigation, resolveContentScriptIsolation} from "@shared/content"; -import AwaitLock from "await-lock"; import EntrypointBuilder from "@entry/core/Builder"; @@ -17,7 +16,10 @@ import {ManagedContext, EventEmitter} from "./context"; import {AttributeMarker, WeakMarker} from "./markers"; import {EventNode} from "./nodes"; +import type {ContentScriptNodeAssembly, ContentScriptCycle} from "./types"; + import { + ContentScriptNode, ContentScriptAnchor, ContentScriptAnchorGetter, ContentScriptBuilder, @@ -28,18 +30,21 @@ import { ContentScriptContext, ContentScriptDefinition, ContentScriptIsolation, + ContentScriptIsolationOptions, ContentScriptMarker, ContentScriptMarkerContract, ContentScriptMarkerGetter, ContentScriptMarkerResolver, ContentScriptMarkerType, ContentScriptMountFunction, - ContentScriptNode, ContentScriptPrepareProps, + ContentScriptPrepareResult, ContentScriptOptions, ContentScriptRenderHandler, ContentScriptRenderValue, ContentScriptResolvedDefinition, + ContentScriptTarget, + ContentScriptTargetCreator, ContentScriptWatchStrategy, } from "@typing/content"; @@ -50,7 +55,7 @@ export default abstract class Builder< extends EntrypointBuilder implements ContentScriptBuilder { - private lock = new AwaitLock(); + private cycle?: ContentScriptCycle; protected generation = 0; @@ -64,31 +69,16 @@ export default abstract class Builder< protected unwatch?: () => void; - protected abstract createNode(anchor: Element, data: Data, enabled: boolean): Promise; + protected abstract createNode( + anchor: Element, + result: ContentScriptPrepareResult | undefined + ): Promise; protected constructor(input: ContentScriptDefinition) { super(); // Normalization selects the isolation at runtime; its node supplies the matching callback props. const definition = input as ContentScriptDefinition; - const isolation = resolveContentScriptIsolation(definition.isolation, "render" in definition); - - if (definition.boundary !== undefined) { - if (typeof definition.boundary !== "function") { - throw new Error("Content script boundary must be a synchronous setup handler"); - } - - if (isolation.type === "none") { - throw new Error("Content script boundary requires Shadow DOM or an iframe"); - } - } - - if ( - definition.target !== undefined && - (isolation.type === "none" || isContentScriptFrameNavigation(isolation)) - ) { - throw new Error("Content script target requires Shadow DOM or an iframe with local rendering"); - } this.definition = { ...definition, @@ -96,9 +86,9 @@ export default abstract class Builder< anchor: this.resolveAnchor(definition.anchor), mount: this.resolveMount(definition.mount), container: this.resolveContainer(definition.container), - target: createTargetResolver(definition.target), - render: definition.render === true ? true : this.resolveRender(definition.render), - isolation, + target: this.resolveTarget(definition.target), + isolation: this.resolveIsolation(definition), + render: this.resolveRender(definition.render), watch: this.resolveWatch(definition.watch), }; } @@ -142,14 +132,41 @@ export default abstract class Builder< return createContainerResolver(container); } + protected resolveTarget(target?: ContentScriptTarget): ContentScriptTargetCreator { + return createTargetResolver(target); + } + + protected resolveIsolation(definition: ContentScriptDefinition): ContentScriptIsolationOptions { + const isolation = resolveContentScriptIsolation(definition.isolation, "render" in definition); + + if (definition.boundary !== undefined) { + if (typeof definition.boundary !== "function") { + throw new Error("Content script boundary must be a synchronous setup handler"); + } + + if (isolation.type === "none") { + throw new Error("Content script boundary requires Shadow DOM or an iframe"); + } + } + + if ( + definition.target !== undefined && + (isolation.type === "none" || isContentScriptFrameNavigation(isolation)) + ) { + throw new Error("Content script target requires Shadow DOM or an iframe with local rendering"); + } + + return isolation; + } + protected resolveRender( render?: ContentScriptRenderValue | ContentScriptRenderHandler - ): ContentScriptRenderHandler | undefined { - if (render !== undefined) { - throw new Error("Content script rendering requires a renderer adapter"); + ): true | ContentScriptRenderHandler | undefined { + if (render === true || render === undefined) { + return render; } - return undefined; + throw new Error("Content script rendering requires a renderer adapter"); } protected resolveWatch(watch?: true | ContentScriptWatchStrategy): ContentScriptWatchStrategy { @@ -178,14 +195,6 @@ export default abstract class Builder< const {render, prepare, main, anchor, marker, container, boundary, target, watch, mount, ...options} = this.definition; - const resolvedMarker = await marker(options); - - if (generation !== this.generation) { - return; - } - - this.marker = resolvedMarker; - await main?.(this.context, options); if (generation !== this.generation) { @@ -197,37 +206,41 @@ export default abstract class Builder< prepare !== undefined || isContentScriptFrameNavigation(this.definition.isolation) ) { - await this.processing(generation); + const resolvedMarker = await marker(options); if (generation !== this.generation) { return; } - this.unwatch = watch(async () => { - if (generation !== this.generation) { - return; - } + this.marker = resolvedMarker; - try { - this.context.mount(); - await this.processing(generation); - } catch (error) { - console.error("Content script processing on watch error", error); - } - }, this.context); + await this.requestProcessing(generation); + + if (generation !== this.generation) { + return; + } + + const onError = (error: unknown): void => { + console.error("Content script processing on watch error", error); + }; + + this.unwatch = watch(() => this.requestProcessing(generation, onError), this.context); } } public async destroy(): Promise { this.generation++; - this.lock = new AwaitLock(); + this.cycle = undefined; this.unwatch?.(); this.unwatch = undefined; - this.context.clear(); - this.context.unwatch(); - this.marker.reset(); + try { + this.context.clear(); + } finally { + this.context.unwatch(); + this.marker.reset(); + } } protected getPrepareProps(anchor: Element): ContentScriptPrepareProps { @@ -248,34 +261,69 @@ export default abstract class Builder< return {...options, anchor}; } - protected async processing(generation: number): Promise { - const lock = this.lock; - await lock.acquireAsync(); + private requestProcessing(generation: number, onError?: (error: unknown) => void): Promise { + if (generation !== this.generation) { + return Promise.resolve(); + } - try { - if (generation !== this.generation) { - return; - } + if (this.cycle?.generation === generation) { + this.cycle.pending = true; - const anchor = await this.definition.anchor(); + return this.cycle.promise; + } - if (generation !== this.generation) { - return; - } + const cycle: ContentScriptCycle = { + generation, + pending: true, + promise: Promise.resolve().then(() => this.drainProcessing(cycle)), + }; + + if (onError) { + // One handler per cycle, not one suspended async callback per watch notification. + cycle.promise = cycle.promise.catch(onError); + } + + this.cycle = cycle; + + return cycle.promise; + } - const anchors = this.marker - .for(anchor) - .unmarked() - .filter(anchor => anchor.isConnected); + private async drainProcessing(cycle: ContentScriptCycle): Promise { + try { + while (cycle.pending && cycle.generation === this.generation) { + cycle.pending = false; + this.context.mount(); - const results = await Promise.allSettled(anchors.map(anchor => this.processAnchor(anchor, generation))); - const errors = results.flatMap(result => (result.status === "rejected" ? [result.reason] : [])); + if (cycle.generation !== this.generation) { + return; + } - if (errors.length && generation === this.generation) { - console.error(new AggregateError(errors, "Content script anchor processing failed")); + await this.processing(cycle.generation); } } finally { - lock.release(); + if (this.cycle === cycle) { + this.cycle = undefined; + } + } + } + + protected async processing(generation: number): Promise { + const anchor = await this.definition.anchor(); + + if (generation !== this.generation) { + return; + } + + const anchors = this.marker + .for(anchor) + .unmarked() + .filter(anchor => anchor.isConnected); + + const results = await Promise.allSettled(anchors.map(anchor => this.processAnchor(anchor, generation))); + const errors = results.flatMap(result => (result.status === "rejected" ? [result.reason] : [])); + + if (errors.length && generation === this.generation) { + console.error(new AggregateError(errors, "Content script anchor processing failed")); } } @@ -287,7 +335,8 @@ export default abstract class Builder< return; } - const node = new EventNode(await this.createNode(anchor, data as Data, data !== false), this.emitter); + const assembly = await this.createNode(anchor, data); + const node = new EventNode(assembly.node, this.emitter, assembly.renderer); if (!current()) { node.unmount(); @@ -295,6 +344,8 @@ export default abstract class Builder< return; } + // Add listeners can mount immediately, so connect the renderer before publishing the node. + assembly.renderer?.setErrorHandler(error => this.handleRenderError(node, error, generation)); this.context.add(node); if (generation !== this.generation) { @@ -312,4 +363,20 @@ export default abstract class Builder< throw error; } } + + private handleRenderError(node: ContentScriptNode, error: unknown, generation: number): void { + if (generation !== this.generation) { + return; + } + + const errors = [error]; + + try { + this.context.remove(node); + } catch (cause) { + errors.push(cause); + } + + console.error(new AggregateError(errors, "Content script deferred mount failed")); + } } diff --git a/src/entry/content/lifecycle/MountBuilder.test.ts b/src/entry/content/lifecycle/MountBuilder.test.ts index c2983e6e..669e5a4d 100644 --- a/src/entry/content/lifecycle/MountBuilder.test.ts +++ b/src/entry/content/lifecycle/MountBuilder.test.ts @@ -1,7 +1,12 @@ import MountBuilder from "./MountBuilder"; import {resolveDefinition} from "../resolvers/definition"; import {defineContentScript} from "@main/content"; -import {ContentScriptEvent, ContentScriptIsolation, type ContentScriptDefinition} from "@typing/content"; +import { + ContentScriptEvent, + ContentScriptIsolation, + ContentScriptMarker, + type ContentScriptDefinition, +} from "@typing/content"; // Extend the shared random-ID mock for the builders' generated marker attribute. jest.mock("nanoid", () => ({ @@ -12,12 +17,235 @@ jest.mock("nanoid", () => ({ describe("MountBuilder", () => { afterEach(() => document.body.replaceChildren()); - test("Common runtime runs main without creating nodes or starting a DOM watcher", async () => { + test("coalesces watched updates across mounting and discovery and awaits every pending pass", async () => { + const initial = document.createElement("article"); + const second = document.createElement("article"); + const third = document.createElement("article"); + document.body.append(initial); + const secondStarted = Promise.withResolvers(); + const secondReady = Promise.withResolvers(); + const thirdStarted = Promise.withResolvers(); + const thirdReady = Promise.withResolvers(); + const anchor = jest.fn(() => "article"); + let update!: () => void | Promise; + + const builder = new MountBuilder({ + anchor, + render: true, + prepare: async ({anchor}) => { + if (anchor === second) { + secondStarted.resolve(); + await secondReady.promise; + } else if (anchor === third) { + thirdStarted.resolve(); + await thirdReady.promise; + } + }, + watch: callback => { + update = callback; + + return () => {}; + }, + }); + + const requests: Promise[] = []; + + try { + await builder.build(); + document.body.append(second); + requests.push(Promise.resolve(update())); + await secondStarted.promise; + + initial.remove(); + document.body.append(third); + + for (let request = 0; request < 5; request++) { + requests.push(Promise.resolve(update())); + } + + expect([...builder.getContext().nodes].map(node => node.anchor)).toEqual([initial]); + let firstCompleted = false; + + void requests[0].then(() => { + firstCompleted = true; + }); + + secondReady.resolve(); + await thirdStarted.promise; + expect(firstCompleted).toBe(false); + thirdReady.resolve(); + await Promise.all(requests); + + expect(anchor).toHaveBeenCalledTimes(3); + expect([...builder.getContext().nodes].map(node => node.anchor)).toEqual([second, third]); + } finally { + secondReady.resolve(); + thirdReady.resolve(); + await Promise.all(requests); + await builder.destroy(); + } + }); + + test("destroy cancels pending passes and stale callbacks cannot start work after rebuild", async () => { + const anchor = jest.fn(() => "article"); + const started = Promise.withResolvers(); + const ready = Promise.withResolvers(); + const currentStarted = Promise.withResolvers(); + const currentReady = Promise.withResolvers(); + const currentAnchor = document.createElement("article"); + const updates: (() => void | Promise)[] = []; + let delay = true; + + const builder = new MountBuilder({ + anchor, + render: true, + prepare: async ({anchor}) => { + if (delay) { + delay = false; + started.resolve(); + await ready.promise; + } else if (anchor === currentAnchor) { + currentStarted.resolve(); + await currentReady.promise; + } + }, + watch: update => { + updates.push(update); + + return () => {}; + }, + }); + + const requests: Promise[] = []; + const currentRequests: Promise[] = []; + + try { + await builder.build(); + document.body.append(document.createElement("article")); + requests.push(Promise.resolve(updates[0]())); + await started.promise; + requests.push(Promise.resolve(updates[0]())); + await builder.destroy(); + await builder.build(); + + document.body.append(currentAnchor); + currentRequests.push(Promise.resolve(updates[1]())); + await currentStarted.promise; + ready.resolve(); + await Promise.all(requests); + await updates[0](); + expect(anchor).toHaveBeenCalledTimes(4); + expect(builder.getContext().nodes.size).toBe(1); + + currentRequests.push(Promise.resolve(updates[1]()), Promise.resolve(updates[1]())); + await Promise.resolve(); + await Promise.resolve(); + expect(anchor).toHaveBeenCalledTimes(4); + currentReady.resolve(); + await Promise.all(currentRequests); + expect(anchor).toHaveBeenCalledTimes(5); + expect(builder.getContext().nodes.size).toBe(2); + } finally { + ready.resolve(); + currentReady.resolve(); + await Promise.all([...requests, ...currentRequests]); + await builder.destroy(); + } + }); + + test("a failed discovery releases the watch cycle for the next request", async () => { + const failure = new Error("Discovery failed"); + const anchor = jest.fn(() => "article"); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + let update!: () => void | Promise; + + const builder = new MountBuilder({ + anchor, + render: true, + watch: callback => { + update = callback; + + return () => {}; + }, + }); + + try { + await builder.build(); + + anchor.mockImplementationOnce(() => { + throw failure; + }); + + await update(); + expect(report).toHaveBeenCalledWith("Content script processing on watch error", failure); + document.body.append(document.createElement("article")); + await update(); + expect(builder.getContext().nodes.size).toBe(1); + } finally { + await builder.destroy(); + report.mockRestore(); + } + }); + + test("watch requests share one completion and report a failed cycle once", async () => { + const failure = new Error("Delayed discovery failed"); + const started = Promise.withResolvers(); + const discovery = Promise.withResolvers(); + const report = jest.spyOn(console, "error").mockImplementation(() => {}); + let delayed = false; + let update!: () => void | Promise; + + const builder = new MountBuilder({ + anchor: () => { + if (delayed) { + started.resolve(); + + return discovery.promise; + } + + return "article"; + }, + render: true, + watch: callback => { + update = callback; + + return () => {}; + }, + }); + + try { + await builder.build(); + delayed = true; + const completion = update(); + await started.promise; + const requests = Array.from({length: 100}, () => update()); + discovery.reject(failure); + await Promise.all([completion, ...requests]); + + for (const request of requests) { + expect(request).toBe(completion); + } + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith("Content script processing on watch error", failure); + delayed = false; + document.body.append(document.createElement("article")); + await update(); + expect(builder.getContext().nodes.size).toBe(1); + } finally { + discovery.resolve("article"); + await builder.destroy(); + report.mockRestore(); + } + }); + + test("Common runtime runs main without resolving a marker, creating nodes or starting a DOM watcher", async () => { const main = jest.fn(); + const marker = jest.fn(() => ContentScriptMarker.Weak); const anchor = jest.fn(() => document.body); const container = jest.fn(() => document.createElement("section")); const watch = jest.fn(() => jest.fn()); - const builder = new MountBuilder(resolveDefinition({default: {main, anchor, container, watch}})); + const builder = new MountBuilder(resolveDefinition({default: {main, marker, anchor, container, watch}})); try { await builder.build(); @@ -30,6 +258,7 @@ describe("MountBuilder", () => { ); expect(main).toHaveBeenCalledTimes(1); + expect(marker).not.toHaveBeenCalled(); expect(anchor).not.toHaveBeenCalled(); expect(container).not.toHaveBeenCalled(); expect(watch).not.toHaveBeenCalled(); @@ -47,6 +276,125 @@ describe("MountBuilder", () => { expect(render).not.toHaveBeenCalled(); }); + test("awaits main before resolving the marker and resolves the marker before processing anchors", async () => { + const mainStarted = Promise.withResolvers(); + const mainReady = Promise.withResolvers(); + const markerStarted = Promise.withResolvers(); + const markerReady = Promise.withResolvers(); + const events: ContentScriptEvent[] = []; + const anchor = jest.fn(() => document.body); + const watch = jest.fn(() => () => {}); + + const marker = jest.fn(() => { + markerStarted.resolve(); + + return markerReady.promise; + }); + + const builder = new MountBuilder( + defineContentScript({ + anchor, + marker, + watch, + render: true, + + main: async context => { + context.watch(event => events.push(event)); + mainStarted.resolve(); + await mainReady.promise; + }, + }) + ); + + const building = builder.build(); + + try { + await mainStarted.promise; + expect(marker).not.toHaveBeenCalled(); + mainReady.resolve(); + await markerStarted.promise; + expect(anchor).not.toHaveBeenCalled(); + expect(watch).not.toHaveBeenCalled(); + markerReady.resolve(ContentScriptMarker.Weak); + await building; + expect(anchor).toHaveBeenCalledTimes(1); + expect(watch).toHaveBeenCalledTimes(1); + expect(events).toEqual([ContentScriptEvent.Add]); + } finally { + mainReady.resolve(); + markerReady.resolve(ContentScriptMarker.Weak); + await building; + await builder.destroy(); + } + }); + + test.each(["main", "marker"])("does not continue initialization after destroy during %s", async stage => { + const started = Promise.withResolvers(); + const released = Promise.withResolvers(); + const anchor = jest.fn(() => document.body); + const prepare = jest.fn(); + const watch = jest.fn(() => () => {}); + + const main = jest.fn(async () => { + if (stage === "main") { + started.resolve(); + await released.promise; + } + }); + + const marker = jest.fn(async () => { + if (stage === "marker") { + started.resolve(); + await released.promise; + } + + return ContentScriptMarker.Weak; + }); + + const builder = new MountBuilder(defineContentScript({main, marker, anchor, prepare, watch})); + const building = builder.build(); + + try { + await started.promise; + await builder.destroy(); + released.resolve(); + await building; + expect(main).toHaveBeenCalledTimes(1); + expect(marker).toHaveBeenCalledTimes(stage === "main" ? 0 : 1); + expect(anchor).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + expect(watch).not.toHaveBeenCalled(); + expect(builder.getContext().nodes.size).toBe(0); + } finally { + released.resolve(); + await building; + await builder.destroy(); + } + }); + + test("rejects marker initialization after main has run without starting anchor processing", async () => { + const error = new Error("Marker initialization failed"); + const main = jest.fn(); + const anchor = jest.fn(() => document.body); + const watch = jest.fn(() => () => {}); + + const marker = jest.fn(async () => { + throw error; + }); + + const builder = new MountBuilder(defineContentScript({main, marker, anchor, watch, render: true})); + + try { + await expect(builder.build()).rejects.toBe(error); + expect(main).toHaveBeenCalledTimes(1); + expect(marker).toHaveBeenCalledTimes(1); + expect(anchor).not.toHaveBeenCalled(); + expect(watch).not.toHaveBeenCalled(); + } finally { + await builder.destroy(); + } + }); + test("Common runtime mounts, remounts and cleans up navigation with lifecycle events", async () => { const anchor = document.createElement("article"); document.body.appendChild(anchor); diff --git a/src/entry/content/lifecycle/MountBuilder.ts b/src/entry/content/lifecycle/MountBuilder.ts index 452a6e72..aac433de 100644 --- a/src/entry/content/lifecycle/MountBuilder.ts +++ b/src/entry/content/lifecycle/MountBuilder.ts @@ -1,12 +1,21 @@ import Builder from "./Builder"; import IsolationSetup from "./IsolationSetup"; import {FrameNode, MountNode, MarkerNode, ShadowNode, Node} from "./nodes"; + import {isContentScriptFrameNavigation} from "@shared/content"; +import type { + ContentScriptIsolationAssembly, + ContentScriptNodeAssembly, + ContentScriptRenderLifecycle, + ContentScriptRenderOptions, +} from "./types"; + import { ContentScriptDefinition, ContentScriptIsolation, ContentScriptNode, + ContentScriptPrepareResult, ContentScriptProps, ContentScriptRenderHandler, } from "@typing/content"; @@ -33,47 +42,72 @@ export default class MountBuilder< }; } - protected async createNode(anchor: Element, data: Data, enabled: boolean): Promise { + protected async createNode( + anchor: Element, + result: ContentScriptPrepareResult | undefined + ): Promise { const navigation = isContentScriptFrameNavigation(this.definition.isolation); const render = this.definition.render; - if (!enabled || (!navigation && (render === undefined || render === true))) { - return new MarkerNode(new Node(anchor), this.marker); + if (result === false || (!navigation && (render === undefined || render === true))) { + return {node: new MarkerNode(new Node(anchor), this.marker)}; } + const data = result as Data; + const mountedNode = await this.createMountNode(anchor, data); + const {node, ready} = this.createIsolation(mountedNode, data); + + if (navigation || typeof render !== "function") { + return {node}; + } + + const renderer = this.createRenderer(node, render, () => this.getProps(node, data), {ready}); + + return {node: renderer, renderer}; + } + + protected async createMountNode(anchor: Element, data: Data): Promise { const marker = this.marker; const container = await this.definition.container({...this.getPrepareProps(anchor), data}); - const mountedNode = new MountNode(new MarkerNode(new Node(anchor, container), marker), this.definition.mount); + return new MountNode( + new MarkerNode(new Node(anchor, container), marker), + this.context.containers, + this.definition.mount + ); + } + + protected createIsolation(node: ContentScriptNode, data: Data): ContentScriptIsolationAssembly { + const options = this.definition.isolation; + + if (options.type === ContentScriptIsolation.None) { + return {node}; + } const setup = new IsolationSetup({ - props: () => ({...this.getPrepareProps(anchor), data}), - container: () => mountedNode.container, + props: () => ({...this.getPrepareProps(node.anchor), data}), + container: () => node.container, boundary: this.definition.boundary, target: this.definition.target, }); - let node: ContentScriptNode = mountedNode; + if (options.type === ContentScriptIsolation.Shadow) { + const shadow = new ShadowNode(node, options, setup); - switch (this.definition.isolation.type) { - case ContentScriptIsolation.Shadow: - node = new ShadowNode(mountedNode, this.definition.isolation, setup); - break; - case ContentScriptIsolation.Iframe: - node = new FrameNode(mountedNode, this.definition.isolation, () => this.context.mount(), setup); - break; + return {node: shadow, ready: () => shadow.ready()}; } - return navigation || typeof render !== "function" - ? node - : this.createRenderer(node, render, () => this.getProps(node, data)); + const frame = new FrameNode(node, options, () => this.context.mount(), setup); + + return {node: frame, ready: () => frame.ready()}; } protected createRenderer( node: ContentScriptNode, render: ContentScriptRenderHandler, - props: () => ContentScriptProps - ): ContentScriptNode { + props: () => ContentScriptProps, + options: ContentScriptRenderOptions + ): ContentScriptNode & ContentScriptRenderLifecycle { throw new Error("Content script rendering requires a renderer adapter"); } } diff --git a/src/entry/content/lifecycle/context/ContainerRegistry.test.ts b/src/entry/content/lifecycle/context/ContainerRegistry.test.ts new file mode 100644 index 00000000..83d3d8cd --- /dev/null +++ b/src/entry/content/lifecycle/context/ContainerRegistry.test.ts @@ -0,0 +1,110 @@ +import ContainerRegistry from "./ContainerRegistry"; + +test("owns registered containers and their descendants until released", () => { + const registry = new ContainerRegistry(); + const container = document.createElement("section"); + const anchor = document.createElement("article"); + const text = document.createTextNode("UI"); + container.append(text); + + expect(registry.owns(text)).toBe(false); + const unregister = registry.register(container, anchor); + expect(registry.owns(container)).toBe(true); + expect(registry.owns(text)).toBe(true); + expect(registry.owns(anchor)).toBe(false); + + unregister(); + unregister(); + expect(registry.owns(text)).toBe(false); +}); + +test.each([false, true])("releases only its own registration (same anchor: %s)", sameAnchor => { + const registry = new ContainerRegistry(); + const container = document.createElement("section"); + const anchor = document.createElement("article"); + const first = registry.register(container, anchor); + const second = registry.register(container, sameAnchor ? anchor : document.createElement("article")); + + first(); + first(); + expect(registry.owns(container)).toBe(true); + second(); + expect(registry.owns(container)).toBe(false); +}); + +test("cleanup from before clear cannot remove a new registration", () => { + const registry = new ContainerRegistry(); + const container = document.createElement("section"); + const anchor = document.createElement("article"); + const stale = registry.register(container, anchor); + registry.clear(); + expect(registry.owns(container)).toBe(false); + const current = registry.register(container, anchor); + + stale(); + expect(registry.owns(container)).toBe(true); + current(); + expect(registry.owns(container)).toBe(false); +}); + +test("checks wrapping anchors at query time across all registrations", () => { + const registry = new ContainerRegistry(); + const container = document.createElement("section"); + const anchor = document.createElement("article"); + registry.register(container, document.createElement("article")); + const unregister = registry.register(container, anchor); + expect(registry.owns(container)).toBe(true); + + container.append(anchor); + expect(registry.owns(anchor)).toBe(false); + expect(registry.owns(container)).toBe(false); + anchor.remove(); + expect(registry.owns(container)).toBe(true); + + container.append(anchor); + unregister(); + expect(registry.owns(container)).toBe(true); +}); + +test.each(["open", "closed"] as const)("crosses nested %s shadow roots", mode => { + const registry = new ContainerRegistry(); + const container = document.createElement("section"); + const root = container.attachShadow({mode}); + const nested = document.createElement("div"); + root.append(nested); + const nestedRoot = nested.attachShadow({mode}); + const text = document.createTextNode("UI"); + nestedRoot.append(text); + const unregister = registry.register(container, document.createElement("article")); + + expect(registry.owns(text)).toBe(true); + expect(registry.owns(root)).toBe(true); + unregister(); + expect(registry.owns(text)).toBe(false); +}); + +test("does not traverse the DOM for an empty registry", () => { + const registry = new ContainerRegistry(); + const target = document.createTextNode("Page"); + const parent = jest.spyOn(target, "parentNode", "get"); + + expect(registry.owns(target)).toBe(false); + expect(parent).not.toHaveBeenCalled(); + parent.mockRestore(); +}); + +test("stops at an iframe document but owns the registered frame host", () => { + const registry = new ContainerRegistry(); + const host = document.createElement("section"); + const iframe = document.createElement("iframe"); + host.append(iframe); + document.body.append(host); + registry.register(host, document.createElement("article")); + + try { + expect(registry.owns(iframe)).toBe(true); + expect(registry.owns(iframe.contentDocument!.body)).toBe(false); + } finally { + host.remove(); + } +}); diff --git a/src/entry/content/lifecycle/context/ContainerRegistry.ts b/src/entry/content/lifecycle/context/ContainerRegistry.ts new file mode 100644 index 00000000..10df76c7 --- /dev/null +++ b/src/entry/content/lifecycle/context/ContainerRegistry.ts @@ -0,0 +1,53 @@ +/** Tracks independent mount registrations without adding ownership to lifecycle nodes. */ +export default class ContainerRegistry { + private readonly registrations = new Map>(); + + public register(container: Element, anchor: Element): () => void { + let registrations = this.registrations.get(container); + + if (!registrations) { + registrations = new Set(); + this.registrations.set(container, registrations); + } + + const registration = {anchor}; + const current = registrations; + current.add(registration); + + return () => { + if (!current.delete(registration)) { + return; + } + + if (current.size === 0 && this.registrations.get(container) === current) { + this.registrations.delete(container); + } + }; + } + + public owns(target: Node): boolean { + if (this.registrations.size === 0) { + return false; + } + + let current: Node | null = target; + + while (current) { + const registrations = this.registrations.get(current); + const container = current; + + if (registrations && !Array.from(registrations).some(({anchor}) => container.contains(anchor))) { + return true; + } + + const shadowRoot: typeof ShadowRoot = current.ownerDocument?.defaultView?.ShadowRoot ?? ShadowRoot; + current = current.parentNode ?? (current instanceof shadowRoot ? current.host : null); + } + + return false; + } + + public clear(): void { + this.registrations.clear(); + } +} diff --git a/src/entry/content/lifecycle/context/Context.test.ts b/src/entry/content/lifecycle/context/Context.test.ts index 5ffd6a91..47499adb 100644 --- a/src/entry/content/lifecycle/context/Context.test.ts +++ b/src/entry/content/lifecycle/context/Context.test.ts @@ -1,29 +1,62 @@ -import {Context} from "./index"; - -import type {ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; - -describe("Context", () => { - test("clears every node after unmounting it and emits removal", () => { - const remove = jest.fn(); - const emitter = { - emitRemove: remove, - } as unknown as ContentScriptEventEmitter; - const context = new (class extends Context { - public add(node: ContentScriptNode): void { - this.collection.add(node); - } - })(emitter); - const node = { - anchor: document.createElement("div"), - mount: jest.fn(), - unmount: jest.fn(() => true), - } satisfies ContentScriptNode; - context.add(node); - - context.clear(); - - expect(node.unmount).toHaveBeenCalledTimes(1); - expect(remove).toHaveBeenCalledWith(node); - expect(context.nodes.size).toBe(0); +import {ManagedContext, EventEmitter} from "./index"; +import {MountNode, Node} from "../nodes"; +import {ContentScriptEvent} from "@typing/content"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +const addPanel = (context: ManagedContext, cleanup?: () => void) => { + const anchor = document.createElement("article"); + const container = document.createElement("section"); + document.body.append(anchor); + + const node = new MountNode(new Node(anchor, container), context.containers, (anchor, container) => { + anchor.append(container); + + return cleanup; }); + context.add(node); + context.mount(); + + return {node, container}; +}; + +test("clears every node and registration and emits removal", () => { + const context = new ManagedContext(new EventEmitter()); + const {node, container} = addPanel(context); + const events = jest.fn(); + context.watch(events); + expect(context.owns(container)).toBe(true); + expect(new ManagedContext(new EventEmitter()).owns(container)).toBe(false); + + context.clear(); + + expect(events).toHaveBeenCalledWith(ContentScriptEvent.Remove, node); + expect(context.nodes.size).toBe(0); + expect(context.owns(container)).toBe(false); + expect(container.isConnected).toBe(false); +}); + +test("clear releases all registrations even when one node cleanup throws", () => { + const context = new ManagedContext(new EventEmitter()); + const first = addPanel(context, () => { + throw new Error("Cleanup failed"); + }); + const second = addPanel(context); + const events = jest.fn(); + context.watch(events); + + expect(() => context.clear()).toThrow("Content script context cleanup failed"); + + for (const {node, container} of [first, second]) { + expect(container.isConnected).toBe(false); + expect(context.owns(container)).toBe(false); + expect(events).toHaveBeenCalledWith(ContentScriptEvent.Remove, node); + } + + expect(context.nodes.size).toBe(0); + const next = addPanel(context); + expect(context.owns(next.container)).toBe(true); + context.clear(); }); diff --git a/src/entry/content/lifecycle/context/Context.ts b/src/entry/content/lifecycle/context/Context.ts index 233d5aba..adf28bd4 100644 --- a/src/entry/content/lifecycle/context/Context.ts +++ b/src/entry/content/lifecycle/context/Context.ts @@ -1,21 +1,29 @@ +import type ContainerRegistry from "./ContainerRegistry"; + import { + ContentScriptNode, ContentScriptContext, ContentScriptEventCallback, ContentScriptEventEmitter, - ContentScriptNode, - ContentScriptNodeSet, } from "@typing/content"; export default class Context implements ContentScriptContext { - protected readonly collection: ContentScriptNodeSet = new Set(); + protected readonly collection = new Set(); private unmounting = false; - constructor(protected readonly emitter: ContentScriptEventEmitter) {} + constructor( + protected readonly emitter: ContentScriptEventEmitter, + private readonly registry: ContainerRegistry + ) {} public get nodes(): ReadonlySet { return this.collection; } + public owns(target: Node): boolean { + return this.registry.owns(target); + } + public mount(): void { if (this.unmounting) { return; @@ -60,23 +68,36 @@ export default class Context implements ContentScriptContext { return; } - node.unmount(); - this.emitter.emitRemove(node); + try { + node.unmount(); + } finally { + this.emitter.emitRemove(node); + } } public clear(): void { const unmounting = this.unmounting; + this.unmounting = true; + const errors: unknown[] = []; + try { for (const node of this.collection) { - node.unmount(); - this.collection.delete(node); - this.emitter.emitRemove(node); + try { + this.remove(node); + } catch (error) { + errors.push(error); + } } } finally { + this.registry.clear(); this.unmounting = unmounting; } + + if (errors.length > 0) { + throw new AggregateError(errors, "Content script context cleanup failed"); + } } public watch(callback: ContentScriptEventCallback): () => void { diff --git a/src/entry/content/lifecycle/context/ManagedContext.ts b/src/entry/content/lifecycle/context/ManagedContext.ts index e9b4e25d..2557a266 100644 --- a/src/entry/content/lifecycle/context/ManagedContext.ts +++ b/src/entry/content/lifecycle/context/ManagedContext.ts @@ -1,8 +1,16 @@ import Context from "./Context"; - -import {ContentScriptNode} from "@typing/content"; +import ContainerRegistry from "./ContainerRegistry"; +import type {ContentScriptNode, ContentScriptEventEmitter} from "@typing/content"; export default class ManagedContext extends Context { + public readonly containers: ContainerRegistry; + + constructor(emitter: ContentScriptEventEmitter) { + const containers = new ContainerRegistry(); + super(emitter, containers); + this.containers = containers; + } + public add(node: ContentScriptNode): this { this.collection.add(node); diff --git a/src/entry/content/lifecycle/context/index.ts b/src/entry/content/lifecycle/context/index.ts index dd9c73c7..0963df8a 100644 --- a/src/entry/content/lifecycle/context/index.ts +++ b/src/entry/content/lifecycle/context/index.ts @@ -1,3 +1,4 @@ export {default as Context} from "./Context"; +export {default as ContainerRegistry} from "./ContainerRegistry"; export {default as ManagedContext} from "./ManagedContext"; export {default as EventEmitter} from "./EventEmitter"; diff --git a/src/entry/content/lifecycle/markers/AttributeMarker.ts b/src/entry/content/lifecycle/markers/AttributeMarker.ts index d7b11788..dd2f71ec 100644 --- a/src/entry/content/lifecycle/markers/AttributeMarker.ts +++ b/src/entry/content/lifecycle/markers/AttributeMarker.ts @@ -18,7 +18,9 @@ export default class AttributeMarker extends AbstractMarker { } public mark(element: Element, value: ContentScriptMarkerValue): boolean { - element.setAttribute(this.attr, value); + if (element.getAttribute(this.attr) !== value) { + element.setAttribute(this.attr, value); + } return true; } diff --git a/src/entry/content/lifecycle/nodes/EventNode.ts b/src/entry/content/lifecycle/nodes/EventNode.ts index 0dac8088..1d78c06b 100644 --- a/src/entry/content/lifecycle/nodes/EventNode.ts +++ b/src/entry/content/lifecycle/nodes/EventNode.ts @@ -1,12 +1,18 @@ -import {ContentScriptBoundary, ContentScriptEventEmitter, ContentScriptNode} from "@typing/content"; +import {ContentScriptNode, ContentScriptBoundary, ContentScriptEventEmitter} from "@typing/content"; +import type {ContentScriptMountNotifier} from "../types"; export default class EventNode implements ContentScriptNode { private generation = 0; constructor( protected readonly node: ContentScriptNode, - protected readonly emitter: ContentScriptEventEmitter - ) {} + protected readonly emitter: ContentScriptEventEmitter, + private readonly notifier?: ContentScriptMountNotifier + ) { + notifier?.setMountHandler(() => { + this.emitter.emitMount(this.node); + }); + } public get anchor(): Element { return this.node.anchor; @@ -32,7 +38,7 @@ export default class EventNode implements ContentScriptNode { return false; } - if (result === true) { + if (result === true && !this.notifier) { this.emitter.emitMount(this.node); } diff --git a/src/entry/content/lifecycle/nodes/FrameNode.test.ts b/src/entry/content/lifecycle/nodes/FrameNode.test.ts index a0d33cf4..e5ae54bb 100644 --- a/src/entry/content/lifecycle/nodes/FrameNode.test.ts +++ b/src/entry/content/lifecycle/nodes/FrameNode.test.ts @@ -5,7 +5,9 @@ import type { ContentScriptIsolationFrameOptions, } from "@typing/content"; -import {FrameNode, Node, MountNode} from "./index"; +import {ContentScriptEvent} from "@typing/content"; +import {FrameNode, Node, MountNode, EventNode} from "./index"; +import {ContainerRegistry, EventEmitter} from "../context"; import IsolationSetup from "../IsolationSetup"; import {getContentScriptStylesRuntime} from "./isolated-styles"; @@ -14,14 +16,20 @@ jest.mock("#adnbn/page", () => ({aliases: {panel: "panel.html"}})); jest.mock("./isolated-styles", () => ({getContentScriptStylesRuntime: jest.fn()})); test("FrameNode explains a disconnected host and allows mounting again once the mounter connects it", () => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); const anchor = document.createElement("section"); const container = document.createElement("div"); document.body.append(anchor); let connect = false; - const mountedNode = new MountNode(new Node(anchor, container), (anchor, container) => { + const mountedNode = new MountNode(new Node(anchor, container), new ContainerRegistry(), (anchor, container) => { if (connect) { anchor.append(container); } @@ -87,8 +95,69 @@ test("FrameNode does not require a connected host for document navigation", () = } }); +test.each([ + {type: "iframe", page: "panel"}, + {type: "iframe", src: "https://example.com/panel"}, +])("FrameNode %j emits no Mount after the mounter cancels its node and allows a later remount", options => { + const anchor = document.createElement("section"); + document.body.append(anchor); + const events: ContentScriptEvent[] = []; + const emitter = new EventEmitter(); + emitter.on(event => events.push(event)); + let cancel = true; + let node: EventNode; + + const mounted = new MountNode( + new Node(anchor, document.createElement("div")), + new ContainerRegistry(), + (anchor, container) => { + anchor.append(container); + + if (cancel) { + node.unmount(); + } + } + ); + + const frame = new FrameNode( + mounted, + options, + () => {}, + new IsolationSetup({ + props: () => ({anchor, data: undefined}), + container: () => mounted.container, + target: ({document}) => document.createElement("div"), + }) + ); + + node = new EventNode(frame, emitter); + + try { + const result = node.mount(); + expect(events).toEqual([ContentScriptEvent.Unmount]); + expect(result).toBe(false); + expect(node.container).toBeUndefined(); + expect(node.boundary).toBeUndefined(); + + cancel = false; + expect(node.mount()).toBe(true); + expect(node.boundary?.isConnected).toBe(true); + expect(node.mount()).toBe(false); + expect(events).toEqual([ContentScriptEvent.Unmount, ContentScriptEvent.Mount]); + } finally { + node.unmount(); + anchor.remove(); + } +}); + test("FrameNode replaces lost targets, unregisters old styles and releases its load callback", async () => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); const anchor = document.createElement("section"); document.body.append(anchor); @@ -100,9 +169,13 @@ test("FrameNode replaces lost targets, unregisters old styles and releases its l return document.createElement("section"); }); - const mountedNode = new MountNode(new Node(anchor, document.createElement("div")), (anchor, container) => { - anchor.append(container); - }); + const mountedNode = new MountNode( + new Node(anchor, document.createElement("div")), + new ContainerRegistry(), + (anchor, container) => { + anchor.append(container); + } + ); const node = new FrameNode( mountedNode, @@ -166,13 +239,21 @@ test("FrameNode replaces lost targets, unregisters old styles and releases its l }); test.each(["throw", "unmount"])("FrameNode cleans up when target creation triggers %s", action => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); const anchor = document.createElement("section"); document.body.append(anchor); - const mountedNode = new MountNode(new Node(anchor, document.createElement("div")), (anchor, container) => - anchor.append(container) + const mountedNode = new MountNode( + new Node(anchor, document.createElement("div")), + new ContainerRegistry(), + (anchor, container) => anchor.append(container) ); const node = new FrameNode( @@ -218,11 +299,18 @@ const createBoundaryNode = ( ({document}: ContentScriptTargetProps) => document.createElement("section") ) ) => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); const mountedNode = new MountNode( new Node(document.body, document.createElement("section")), + new ContainerRegistry(), (anchor, container) => { anchor.append(container); } diff --git a/src/entry/content/lifecycle/nodes/FrameNode.ts b/src/entry/content/lifecycle/nodes/FrameNode.ts index d62776f1..00314f4c 100644 --- a/src/entry/content/lifecycle/nodes/FrameNode.ts +++ b/src/entry/content/lifecycle/nodes/FrameNode.ts @@ -1,9 +1,9 @@ import {getPageUrl} from "@main/page"; import type { + ContentScriptNode, ContentScriptBoundaryCleanup, ContentScriptIsolationFrameOptions, - ContentScriptNode, ContentScriptStylesRuntime, } from "@typing/content"; import {ContentScriptIsolation} from "@typing/content"; @@ -53,6 +53,10 @@ export default class FrameNode implements ContentScriptNode { return this.frame; } + public ready(): Promise { + return this.head && this.runtime ? this.runtime.ready(this.head) : Promise.resolve(); + } + private readonly onLoad = (): void => { if (!this.frame || this.queued || this.intact()) { return; diff --git a/src/entry/content/lifecycle/nodes/MarkerNode.ts b/src/entry/content/lifecycle/nodes/MarkerNode.ts index 443fdfe5..65767f64 100644 --- a/src/entry/content/lifecycle/nodes/MarkerNode.ts +++ b/src/entry/content/lifecycle/nodes/MarkerNode.ts @@ -1,4 +1,4 @@ -import {ContentScriptBoundary, ContentScriptMarkerContract, ContentScriptNode} from "@typing/content"; +import {ContentScriptNode, ContentScriptBoundary, ContentScriptMarkerContract} from "@typing/content"; export default class MarkerNode implements ContentScriptNode { constructor( diff --git a/src/entry/content/lifecycle/nodes/MountNode.test.ts b/src/entry/content/lifecycle/nodes/MountNode.test.ts new file mode 100644 index 00000000..66ee9acc --- /dev/null +++ b/src/entry/content/lifecycle/nodes/MountNode.test.ts @@ -0,0 +1,160 @@ +import {ContainerRegistry} from "../context"; +import {MountNode, Node} from "./index"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +test("a failed mounter rolls back its registration and allows retry", () => { + const registry = new ContainerRegistry(); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + document.body.append(anchor); + let fail = true; + + const node = new MountNode(new Node(anchor, container), registry, (anchor, container) => { + if (fail) { + throw new Error("Mount failed"); + } + + anchor.append(container); + }); + + expect(() => node.mount()).toThrow("Mount failed"); + expect(registry.owns(container)).toBe(false); + fail = false; + expect(node.mount()).toBe(true); + expect(registry.owns(container)).toBe(true); + node.unmount(); + expect(registry.owns(container)).toBe(false); +}); + +test("synchronous cancellation inside the mounter releases even late returned cleanup", () => { + const registry = new ContainerRegistry(); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + const cleanup = jest.fn(); + document.body.append(anchor); + + const node = new MountNode(new Node(anchor, container), registry, (anchor, container) => { + anchor.append(container); + node.unmount(); + expect(registry.owns(container)).toBe(false); + + return cleanup; + }); + + expect(node.mount()).toBe(false); + expect(registry.owns(container)).toBe(false); + expect(container.isConnected).toBe(false); + expect(cleanup).toHaveBeenCalledTimes(1); + node.unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); +}); + +test.each(["return", "throw"])("a stale mount cannot unregister a reentrant remount on %s", outcome => { + const registry = new ContainerRegistry(); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + const staleCleanup = jest.fn(); + const currentCleanup = jest.fn(); + document.body.append(anchor); + let first = true; + + const node = new MountNode(new Node(anchor, container), registry, (anchor, container) => { + anchor.append(container); + + if (!first) { + return currentCleanup; + } + + first = false; + node.unmount(); + node.mount(); + + if (outcome === "throw") { + throw new Error("Stale mount failed"); + } + + return staleCleanup; + }); + + if (outcome === "throw") { + expect(() => node.mount()).toThrow("Stale mount failed"); + } else { + expect(node.mount()).toBe(false); + expect(staleCleanup).toHaveBeenCalledTimes(1); + } + + expect(node.container).not.toBe(container); + expect(registry.owns(container)).toBe(false); + expect(registry.owns(node.container!)).toBe(true); + expect(currentCleanup).not.toHaveBeenCalled(); + node.unmount(); + expect(currentCleanup).toHaveBeenCalledTimes(1); +}); + +test("throwing cleanup still removes the host and cannot remount it during unmount", () => { + const registry = new ContainerRegistry(); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + document.body.append(anchor); + + const node = new MountNode(new Node(anchor, container), registry, (anchor, container) => { + anchor.append(container); + + return () => { + expect(registry.owns(container)).toBe(false); + expect(node.mount()).toBe(false); + expect(node.unmount()).toBe(false); + + throw new Error("Cleanup failed"); + }; + }); + + node.mount(); + expect(() => node.unmount()).toThrow("Cleanup failed"); + expect(container.isConnected).toBe(false); + expect(registry.owns(container)).toBe(false); + expect(node.container).toBeUndefined(); + expect(node.unmount()).toBe(false); +}); + +test("a shared container keeps the other mount registration when one node unmounts", () => { + const registry = new ContainerRegistry(); + const container = document.createElement("section"); + const first = new MountNode(new Node(document.createElement("article"), container), registry, () => {}); + const second = new MountNode(new Node(document.createElement("article"), container), registry, () => {}); + first.mount(); + second.mount(); + + first.unmount(); + expect(registry.owns(container)).toBe(true); + second.unmount(); + expect(registry.owns(container)).toBe(false); +}); + +test("registers before the mounter and releases before its cleanup", () => { + const registry = new ContainerRegistry(); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + document.body.append(anchor); + const cleanup = jest.fn(() => { + expect(registry.owns(container)).toBe(false); + expect(container.isConnected).toBe(true); + }); + + const node = new MountNode(new Node(anchor, container), registry, (anchor, container) => { + expect(registry.owns(container)).toBe(true); + anchor.append(container); + + return cleanup; + }); + + expect(node.mount()).toBe(true); + expect(node.unmount()).toBe(true); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(container.isConnected).toBe(false); + node.unmount(); + expect(cleanup).toHaveBeenCalledTimes(1); +}); diff --git a/src/entry/content/lifecycle/nodes/MountNode.ts b/src/entry/content/lifecycle/nodes/MountNode.ts index 7d596ea6..dc37ff8f 100644 --- a/src/entry/content/lifecycle/nodes/MountNode.ts +++ b/src/entry/content/lifecycle/nodes/MountNode.ts @@ -1,10 +1,14 @@ -import {ContentScriptBoundary, ContentScriptMountFunction, ContentScriptNode} from "@typing/content"; +import {ContentScriptNode, ContentScriptBoundary, ContentScriptMountFunction} from "@typing/content"; +import type ContainerRegistry from "../context/ContainerRegistry"; export default class MountNode implements ContentScriptNode { - private unmounting?: () => void; + private unregister?: () => void; + private cleanup?: () => void; + private unmounting = false; constructor( protected node: ContentScriptNode, + private readonly registry: ContainerRegistry, protected mounter?: ContentScriptMountFunction ) {} @@ -25,29 +29,69 @@ export default class MountNode implements ContentScriptNode { } public mount(): boolean { + if (this.unmounting) { + return false; + } + this.node.mount(); + const container = this.container; - if (!this.container || this.container.isConnected) { + if (!container || container.isConnected || !this.mounter) { return false; } - if (this.mounter) { - const unmounting = this.mounter(this.anchor, this.container); + this.unregister?.(); + const unregister = this.registry.register(container, this.anchor); + this.unregister = unregister; + + try { + const cleanup = this.mounter(this.anchor, container); + + if (this.unregister !== unregister || this.container !== container) { + // A synchronous cancellation must not retain cleanup or revive the registration. + cleanup?.(); - if (unmounting) { - this.unmounting = unmounting; + return false; } + this.cleanup = cleanup || undefined; + return true; - } + } catch (error) { + unregister(); + + if (this.unregister === unregister) { + this.unregister = undefined; + } - return false; + throw error; + } } public unmount(): boolean { - this.unmounting?.(); - this.unmounting = undefined; + if (this.unmounting) { + return false; + } + + const unregister = this.unregister; + const cleanup = this.cleanup; + this.unregister = undefined; + this.cleanup = undefined; + this.unmounting = true; + let removed = false; + + try { + unregister?.(); + + try { + cleanup?.(); + } finally { + removed = !!this.node.unmount(); + } + } finally { + this.unmounting = false; + } - return !!this.node.unmount(); + return removed; } } diff --git a/src/entry/content/lifecycle/nodes/Node.ts b/src/entry/content/lifecycle/nodes/Node.ts index f21b383c..cfa48795 100644 --- a/src/entry/content/lifecycle/nodes/Node.ts +++ b/src/entry/content/lifecycle/nodes/Node.ts @@ -1,4 +1,4 @@ -import {ContentScriptNode} from "@typing/content"; +import type {ContentScriptNode} from "@typing/content"; export default class Node implements ContentScriptNode { private readonly _container?: Element; diff --git a/src/entry/content/lifecycle/nodes/RenderNode.test.ts b/src/entry/content/lifecycle/nodes/RenderNode.test.ts new file mode 100644 index 00000000..8e20a3bd --- /dev/null +++ b/src/entry/content/lifecycle/nodes/RenderNode.test.ts @@ -0,0 +1,486 @@ +import {readContentStyles} from "#adnbn/runtime"; +import {renderIsolatedStylesRuntime} from "@cli/bundler/plugins/isolated-styles/templates"; +import {ContentScriptEvent, type ContentScriptStylesRuntime} from "@typing/content"; +import VanillaNode from "../../adapters/vanilla/Node"; +import ReactNode from "../../adapters/react/Node"; +import {act, waitFor} from "@testing-library/react"; +import {createElement, useEffect, useLayoutEffect} from "react"; +import IsolationSetup from "../IsolationSetup"; +import {ContainerRegistry, EventEmitter} from "../context"; +import {EventNode, FrameNode, MountNode, Node, ShadowNode} from "./index"; + +jest.mock("#adnbn/runtime", () => ({readContentStyles: jest.fn()})); + +const createRuntime = (files = ["first.css", "second.css"]): ContentScriptStylesRuntime => { + const registry: Record = {}; + + new Function( + "registry", + renderIsolatedStylesRuntime({ + entry: "panel.content", + require: "registry", + property: "styles", + timeout: 1000, + initialStyles: files, + }) + )(registry); + + registry.styles.initialize(file => `https://extension.test/${file}`); + jest.mocked(readContentStyles).mockReturnValue(registry.styles); + + return registry.styles; +}; + +const createNode = (mode: "shadow" | "iframe" = "shadow") => { + const anchor = document.createElement("article"); + document.body.append(anchor); + + const mount = jest.fn((anchor: Element, container: Element) => { + anchor.append(container); + }); + const host = new MountNode(new Node(anchor, document.createElement("section")), new ContainerRegistry(), mount); + + const setup = new IsolationSetup({ + props: () => ({anchor, data: undefined}), + container: () => host.container, + target: ({document}) => document.createElement("div"), + }); + + const isolated = + mode === "shadow" + ? new ShadowNode(host, {}, setup) + : new FrameNode( + host, + {type: "iframe"}, + () => { + node.mount(); + }, + setup + ); + + const render = jest.fn(() => "Styled UI"); + const onError = jest.fn(); + const renderer = new VanillaNode( + isolated, + render, + () => ({ + anchor, + container: isolated.container!, + target: isolated.target!, + boundary: isolated.boundary, + data: undefined, + }), + {ready: () => isolated.ready()} + ); + renderer.setErrorHandler(onError); + + const emitter = new EventEmitter(); + const events: ContentScriptEvent[] = []; + emitter.on(event => events.push(event)); + const node = new EventNode(renderer, emitter, renderer); + + return { + node, + mount, + onError, + render, + events, + isolated, + emitter, + props: () => ({ + anchor, + container: isolated.container!, + target: isolated.target!, + boundary: isolated.boundary, + data: undefined, + }), + }; +}; + +const links = (node: EventNode): HTMLLinkElement[] => { + const root = node.target!.getRootNode() as ShadowRoot | Document; + + return Array.from(root.querySelectorAll('link[rel="stylesheet"]')); +}; + +afterEach(() => { + document.body.replaceChildren(); + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + +test("a renderer cleanup failure still unmounts the underlying host", () => { + const failure = new Error("Renderer cleanup failed"); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + document.body.append(anchor); + const registry = new ContainerRegistry(); + const mounted = new MountNode(new Node(anchor, container), registry, (anchor, container) => + anchor.append(container) + ); + + class FailingNode extends VanillaNode { + protected clear(): void { + if (container.textContent) { + throw failure; + } + } + } + + const renderer = new FailingNode( + mounted, + () => "UI", + () => ({anchor, container, target: container, boundary: undefined, data: undefined}) + ); + expect(renderer.mount()).toBe(true); + expect(registry.owns(container)).toBe(true); + expect(() => renderer.unmount()).toThrow(failure); + expect(container.isConnected).toBe(false); + expect(registry.owns(container)).toBe(false); + expect(mounted.container).toBeUndefined(); +}); + +test("requires an error handler before mounting a deferred renderer and accepts it after construction", async () => { + createRuntime(["initial.css"]); + const {isolated, props, render, onError, emitter, events} = createNode(); + const renderer = new VanillaNode(isolated, render, props, {ready: () => isolated.ready()}); + const node = new EventNode(renderer, emitter, renderer); + + try { + expect(() => node.mount()).toThrow("Deferred content rendering requires an error handler before mount"); + expect(isolated.container?.isConnected).toBe(false); + expect(render).not.toHaveBeenCalled(); + + renderer.setErrorHandler(onError); + expect(node.mount()).toBe(false); + links(node)[0].dispatchEvent(new Event("load")); + await waitFor(() => expect(events).toEqual([ContentScriptEvent.Mount])); + expect(render).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + } finally { + node.unmount(); + } +}); + +test.each(["shadow", "iframe"] as const)( + "%s invalidates pending render on unmount and awaits fresh links on remount", + async mode => { + createRuntime(); + const {node, render, events} = createNode(mode); + + try { + expect(node.mount()).toBe(false); + const oldTarget = node.target; + const oldLinks = links(node); + expect(node.unmount()).toBe(true); + expect(node.mount()).toBe(false); + expect(node.target).not.toBe(oldTarget); + await act(async () => { + for (const link of oldLinks) { + expect(link.onload).toBeNull(); + expect(link.onerror).toBeNull(); + link.dispatchEvent(new Event("load")); + } + }); + + expect(render).not.toHaveBeenCalled(); + + for (const link of links(node)) { + link.dispatchEvent(new Event("load")); + } + + await waitFor(() => expect(render).toHaveBeenCalledTimes(1)); + expect(events.filter(event => event === ContentScriptEvent.Mount)).toHaveLength(1); + } finally { + node.unmount(); + } + } +); + +test("a timeout releases links and timers without rendering, and the next mount can retry", async () => { + jest.useFakeTimers(); + createRuntime(["stalled.css"]); + const error = jest.spyOn(console, "error").mockImplementation(() => {}); + const {node, render, events} = createNode(); + + try { + expect(node.mount()).toBe(false); + await jest.advanceTimersByTimeAsync(1000); + expect(render).not.toHaveBeenCalled(); + expect(events).toEqual([]); + expect(links(node)).toEqual([]); + expect(jest.getTimerCount()).toBe(0); + expect(error.mock.calls[0][0]).toMatchObject({type: "timeout", request: "https://extension.test/stalled.css"}); + + expect(node.mount()).toBe(false); + links(node)[0].dispatchEvent(new Event("load")); + await jest.advanceTimersByTimeAsync(0); + expect(jest.getTimerCount()).toBe(0); + expect(events).toEqual([ContentScriptEvent.Mount]); + } finally { + node.unmount(); + } +}); + +test("ready is idempotent, includes newly requested CSS, and does not preload future chunks", async () => { + const runtime = createRuntime(["initial.css"]); + const root = document.createElement("section").attachShadow({mode: "open"}); + runtime.add(root, null); + + try { + const first = runtime.ready(root); + const again = runtime.ready(root); + expect(root.querySelectorAll("link")).toHaveLength(1); + root.querySelector("link")!.dispatchEvent(new Event("load")); + await Promise.all([first, again]); + const lazy = runtime.load("https://extension.test/lazy.css"); + const later = runtime.ready(root); + let ready = false; + void later.then(() => { + ready = true; + }); + await Promise.resolve(); + expect(ready).toBe(false); + expect(root.querySelectorAll("link")).toHaveLength(2); + root.querySelectorAll("link")[1].dispatchEvent(new Event("load")); + await Promise.all([lazy, later]); + expect(ready).toBe(true); + } finally { + runtime.delete(root); + } +}); + +test("iframe recovery invalidates pending CSS and waits for the new target without duplicate Mount events", async () => { + createRuntime(["initial.css"]); + const {node, render, events} = createNode("iframe"); + + try { + expect(node.mount()).toBe(false); + const frame = node.boundary as HTMLIFrameElement; + const target = node.target!; + target.remove(); + await act(async () => { + frame.dispatchEvent(new Event("load")); + }); + expect(node.target).not.toBe(target); + expect(render).not.toHaveBeenCalled(); + expect(node.mount()).toBe(false); + await act(async () => { + links(node)[0].dispatchEvent(new Event("load")); + }); + await act(async () => { + frame.dispatchEvent(new Event("load")); + }); + expect(render).toHaveBeenCalledTimes(1); + expect(events).toEqual([ContentScriptEvent.Mount]); + } finally { + node.unmount(); + } +}); + +test("React component, layout effects and effects start only after CSS is ready", async () => { + createRuntime(["initial.css"]); + const {isolated, emitter, events, props, onError} = createNode(); + const layout = jest.fn(); + const effect = jest.fn(); + const cleanup = jest.fn(); + + const Panel = jest.fn(() => { + useLayoutEffect(layout, []); + useEffect(() => { + effect(); + + return cleanup; + }, []); + + return createElement("span", null, "React UI"); + }); + + const renderer = new ReactNode(isolated, () => createElement(Panel), props, { + ready: () => isolated.ready(), + }); + renderer.setErrorHandler(onError); + const node = new EventNode(renderer, emitter, renderer); + + try { + await act(() => { + expect(node.mount()).toBe(false); + }); + expect(Panel).not.toHaveBeenCalled(); + expect(layout).not.toHaveBeenCalled(); + expect(effect).not.toHaveBeenCalled(); + expect(events).toEqual([]); + + await act(async () => { + links(node)[0].dispatchEvent(new Event("load")); + }); + + expect(node.target!.textContent).toBe("React UI"); + expect(layout).toHaveBeenCalledTimes(1); + expect(effect).toHaveBeenCalledTimes(1); + expect(events).toEqual([ContentScriptEvent.Mount]); + } finally { + await act(() => { + node.unmount(); + }); + } + + expect(cleanup).toHaveBeenCalledTimes(1); +}); + +test("an empty CSS inventory needs no load events before rendering", async () => { + createRuntime([]); + const {node, render, events} = createNode(); + + try { + expect(node.mount()).toBe(false); + await waitFor(() => expect(render).toHaveBeenCalledTimes(1)); + expect(links(node)).toEqual([]); + expect(events).toEqual([ContentScriptEvent.Mount]); + } finally { + node.unmount(); + } +}); + +test("a removed anchor cannot render when pending styles finish", async () => { + createRuntime(["initial.css"]); + const {node, render, events} = createNode(); + + try { + expect(node.mount()).toBe(false); + const [link] = links(node); + node.anchor.remove(); + await act(async () => { + link.dispatchEvent(new Event("load")); + }); + expect(node.target).toBeUndefined(); + expect(render).not.toHaveBeenCalled(); + expect(events).toEqual([]); + } finally { + node.unmount(); + } +}); + +test.each(["shadow", "iframe"] as const)( + "%s emits no Mount when the deferred render handler unmounts its own node", + async mode => { + createRuntime(["initial.css"]); + const {node, render, events, onError} = createNode(mode); + + render.mockImplementationOnce(() => { + node.unmount(); + + return "Discarded UI"; + }); + + try { + expect(node.mount()).toBe(false); + await act(async () => { + links(node)[0].dispatchEvent(new Event("load")); + }); + + expect(render).toHaveBeenCalledTimes(1); + expect(node.target).toBeUndefined(); + expect(node.anchor.textContent).toBe(""); + expect(events).toEqual([ContentScriptEvent.Unmount]); + expect(onError).not.toHaveBeenCalled(); + } finally { + node.unmount(); + } + } +); + +test("roots wait independently and removing a pending root clears its timers", async () => { + jest.useFakeTimers(); + createRuntime(["initial.css"]); + const first = createNode(); + const second = createNode(); + + try { + expect(first.node.mount()).toBe(false); + expect(second.node.mount()).toBe(false); + links(second.node)[0].dispatchEvent(new Event("load")); + await jest.advanceTimersByTimeAsync(0); + expect(first.render).not.toHaveBeenCalled(); + expect(second.render).toHaveBeenCalledTimes(1); + expect(first.node.unmount()).toBe(true); + await jest.advanceTimersByTimeAsync(0); + expect(jest.getTimerCount()).toBe(0); + expect(first.events).not.toContain(ContentScriptEvent.Mount); + } finally { + first.node.unmount(); + second.node.unmount(); + } +}); + +test.each(["shadow", "iframe"] as const)( + "%s waits for initial and remembered lazy CSS and emits Mount once", + async mode => { + const runtime = createRuntime(); + await runtime.load("https://extension.test/lazy.css"); + const {node, render, events, mount, isolated} = createNode(mode); + const mountNode = jest.spyOn(isolated, "mount"); + + try { + const mounting = node.mount(); + expect(mounting).toBe(false); + expect(render).not.toHaveBeenCalled(); + expect(node.target!.textContent).toBe(""); + expect(events).toEqual([]); + expect(node.mount()).toBe(mounting); + const mountCalls = mountNode.mock.calls.length; + const requested = links(node); + expect(requested.map(link => link.href)).toEqual([ + "https://extension.test/first.css", + "https://extension.test/second.css", + "https://extension.test/lazy.css", + ]); + + requested[1].dispatchEvent(new Event("load")); + requested[0].dispatchEvent(new Event("load")); + await Promise.resolve(); + expect(render).not.toHaveBeenCalled(); + requested[2].dispatchEvent(new Event("load")); + await waitFor(() => expect(render).toHaveBeenCalledTimes(1)); + expect(mountNode).toHaveBeenCalledTimes(mountCalls); + expect(mount).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(1); + expect(node.target!.textContent).toBe("Styled UI"); + expect(events).toEqual([ContentScriptEvent.Mount]); + expect(node.mount()).toBe(false); + } finally { + node.unmount(); + } + } +); + +test("failed CSS leaves the target empty and retries only missing links in their original order", async () => { + createRuntime(); + const error = jest.spyOn(console, "error").mockImplementation(() => {}); + const {node, render, events} = createNode(); + + try { + expect(node.mount()).toBe(false); + const [first, second] = links(node); + await act(async () => { + second.dispatchEvent(new Event("load")); + first.dispatchEvent(new Event("error")); + }); + expect(render).not.toHaveBeenCalled(); + expect(events).toEqual([]); + expect(first.isConnected).toBe(false); + expect(error).toHaveBeenCalledTimes(1); + expect(error.mock.calls[0][0]).toMatchObject({request: first.href, type: "error"}); + expect(String(error.mock.calls[0][0])).toContain("panel.content"); + + expect(node.mount()).toBe(false); + const current = links(node); + expect(current.map(link => link.href)).toEqual([first.href, second.href]); + expect(current[1]).toBe(second); + current[0].dispatchEvent(new Event("load")); + await waitFor(() => expect(render).toHaveBeenCalledTimes(1)); + expect(events).toEqual([ContentScriptEvent.Mount]); + } finally { + node.unmount(); + } +}); diff --git a/src/entry/content/lifecycle/nodes/RenderNode.ts b/src/entry/content/lifecycle/nodes/RenderNode.ts index 3bff9de3..8ad0767c 100644 --- a/src/entry/content/lifecycle/nodes/RenderNode.ts +++ b/src/entry/content/lifecycle/nodes/RenderNode.ts @@ -1,21 +1,26 @@ import type { - ContentScriptBoundary, ContentScriptNode, + ContentScriptBoundary, ContentScriptProps, ContentScriptRenderHandler, ContentScriptRenderValue, } from "@typing/content"; +import type {ContentScriptRenderLifecycle, ContentScriptRenderOptions} from "../types"; -/** Runs the adapter only after mounting DOM, and invalidates work when its target is replaced. */ -export default abstract class RenderNode implements ContentScriptNode { +/** Runs the adapter only after mounting DOM and invalidates work when its target is replaced. */ +export default abstract class RenderNode implements ContentScriptNode, ContentScriptRenderLifecycle { private generation = 0; private renderedTarget?: Element; + private pendingTarget?: Element; + private mountHandler?: () => void; + private errorHandler?: (error: unknown) => void; private empty = false; constructor( protected readonly node: ContentScriptNode, private readonly resolve: ContentScriptRenderHandler, - private readonly props: () => ContentScriptProps + private readonly props: () => ContentScriptProps, + private readonly options?: ContentScriptRenderOptions ) {} public get anchor(): Element { @@ -38,11 +43,26 @@ export default abstract class RenderNode implements ContentScrip protected abstract clear(): void; + public setMountHandler(handler: () => void): void { + this.mountHandler = handler; + } + + public setErrorHandler(handler: (error: unknown) => void): void { + this.errorHandler = handler; + } + public mount(): boolean { if (this.empty || !this.anchor.isConnected) { return false; } + const ready = this.options?.ready; + const onError = this.errorHandler; + + if (ready && !onError) { + throw new Error("Deferred content rendering requires an error handler before mount"); + } + this.node.mount(); const target = this.target; @@ -51,17 +71,73 @@ export default abstract class RenderNode implements ContentScrip return false; } - if (target === this.renderedTarget) { + if (target === this.renderedTarget || target === this.pendingTarget) { return false; } this.clear(); const generation = ++this.generation; + this.pendingTarget = undefined; - this.renderedTarget = target; const replacedAnchor = !this.anchor.isConnected; + if (ready && onError) { + const loading = ready(); + this.pendingTarget = target; + + void loading.then( + () => { + if (generation !== this.generation || target !== this.target) { + return; + } + + this.pendingTarget = undefined; + + try { + this.completeRender(target, generation, replacedAnchor); + } catch (error) { + onError(error); + } + }, + () => { + // The style runtime reports the failure. A later mount can retry this target. + if (generation === this.generation) { + this.pendingTarget = undefined; + } + } + ); + + return false; + } + + return this.completeRender(target, generation, replacedAnchor); + } + + private completeRender(target: Element, generation: number, replacedAnchor: boolean): boolean { + const rendered = this.renderTarget(target, generation, replacedAnchor); + + if (rendered && generation === this.generation && target === this.target) { + this.mountHandler?.(); + } + + return rendered; + } + + private renderTarget(target: Element, generation: number, replacedAnchor: boolean): boolean { + if (generation !== this.generation || target !== this.target) { + return false; + } + + if (!this.anchor.isConnected && !(replacedAnchor && this.container?.isConnected)) { + this.unmount(); + + return false; + } + + // Prevent a render callback from recursively rendering into the same target. + this.renderedTarget = target; + try { const value = this.resolve(this.props()); @@ -98,9 +174,16 @@ export default abstract class RenderNode implements ContentScrip public unmount(): boolean { this.generation++; this.renderedTarget = undefined; + this.pendingTarget = undefined; - this.clear(); + let removed = false; + + try { + this.clear(); + } finally { + removed = !!this.node.unmount(); + } - return !!this.node.unmount(); + return removed; } } diff --git a/src/entry/content/lifecycle/nodes/ShadowNode.test.ts b/src/entry/content/lifecycle/nodes/ShadowNode.test.ts index 0f45cd3f..36602de3 100644 --- a/src/entry/content/lifecycle/nodes/ShadowNode.test.ts +++ b/src/entry/content/lifecycle/nodes/ShadowNode.test.ts @@ -1,3 +1,4 @@ +import {ContainerRegistry} from "../context"; import {MountNode, Node, ShadowNode} from "./index"; import IsolationSetup from "../IsolationSetup"; import {getContentScriptStylesRuntime} from "./isolated-styles"; @@ -27,7 +28,7 @@ const createNode = ( document.body.appendChild(anchor); jest.mocked(getContentScriptStylesRuntime).mockReturnValue(runtime); - const mountedNode = new MountNode(new Node(anchor, host), (_anchor, container) => { + const mountedNode = new MountNode(new Node(anchor, host), new ContainerRegistry(), (_anchor, container) => { anchor.appendChild(container); }); @@ -57,6 +58,7 @@ describe("ShadowNode", () => { const runtime: ContentScriptStylesRuntime = { initialize: jest.fn(), add: jest.fn(), + ready: jest.fn(async () => undefined), delete: jest.fn(), load: jest.fn(async () => undefined), }; @@ -86,6 +88,7 @@ describe("ShadowNode", () => { const runtime: ContentScriptStylesRuntime = { initialize: jest.fn(), add: jest.fn(), + ready: jest.fn(async () => undefined), delete: jest.fn(root => states.push((root as ShadowRoot).host.isConnected)), load: jest.fn(async () => undefined), }; @@ -115,7 +118,13 @@ describe("ShadowNode", () => { ); test("creates a custom target per mount with access to a closed root", () => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; const factory = jest.fn(({boundary, document}: ContentScriptTargetProps) => { expect(boundary.mode).toBe("closed"); @@ -153,7 +162,13 @@ describe("ShadowNode", () => { }); test("releases the host when target creation fails and can mount a fresh root", () => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; const factory = jest.fn(({document}: ContentScriptTargetProps) => document.createElement("span") @@ -180,7 +195,13 @@ describe("ShadowNode", () => { }); test("does not register a root unmounted by the target factory", () => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; const {node} = createNode(runtime, undefined, undefined, ({document}) => { node.unmount(); @@ -200,6 +221,7 @@ describe("ShadowNode", () => { const runtime: ContentScriptStylesRuntime = { initialize: jest.fn(), add: jest.fn(), + ready: jest.fn(async () => undefined), delete: jest.fn(), load: jest.fn(async () => undefined), }; @@ -224,6 +246,7 @@ describe("ShadowNode", () => { const runtime: ContentScriptStylesRuntime = { initialize: jest.fn(), add: jest.fn(), + ready: jest.fn(async () => undefined), delete: jest.fn(), load: jest.fn(async () => undefined), }; @@ -240,6 +263,7 @@ describe("ShadowNode", () => { const runtime: ContentScriptStylesRuntime = { initialize: jest.fn(), add: jest.fn(), + ready: jest.fn(async () => undefined), delete: jest.fn(), load: jest.fn(async () => undefined), }; @@ -254,7 +278,13 @@ describe("ShadowNode", () => { }); test("ShadowNode sets up each closed boundary once and cleans subscriptions before removing it", () => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; const calls: string[] = []; const cleanup = jest.fn(); @@ -296,7 +326,13 @@ test("ShadowNode sets up each closed boundary once and cleans subscriptions befo }); test.each(["setup", "target", "unmount"])("ShadowNode releases boundary resources after %s", action => { - const runtime = {initialize: jest.fn(), add: jest.fn(), delete: jest.fn(), load: jest.fn()}; + const runtime = { + initialize: jest.fn(), + add: jest.fn(), + ready: jest.fn(async () => undefined), + delete: jest.fn(), + load: jest.fn(), + }; const cleanup = jest.fn(); const {node} = createNode( diff --git a/src/entry/content/lifecycle/nodes/ShadowNode.ts b/src/entry/content/lifecycle/nodes/ShadowNode.ts index c2e2f638..1b1cb048 100644 --- a/src/entry/content/lifecycle/nodes/ShadowNode.ts +++ b/src/entry/content/lifecycle/nodes/ShadowNode.ts @@ -3,10 +3,10 @@ import type IsolationSetup from "../IsolationSetup"; import {getContentScriptStylesRuntime} from "./isolated-styles"; import { + ContentScriptNode, ContentScriptShadowMode, type ContentScriptShadowOptions, type ContentScriptStylesRuntime, - type ContentScriptNode, type ContentScriptBoundaryCleanup, } from "@typing/content"; @@ -43,6 +43,10 @@ export default class ShadowNode implements ContentScriptNode { return this.root; } + public ready(): Promise { + return this.root && this.runtime ? this.runtime.ready(this.root) : Promise.resolve(); + } + public mount(): boolean { if (this.mounting || this.unmounting) { return false; diff --git a/src/entry/content/lifecycle/nodes/isolated-styles.test.ts b/src/entry/content/lifecycle/nodes/isolated-styles.test.ts index a4be9475..35ad06a8 100644 --- a/src/entry/content/lifecycle/nodes/isolated-styles.test.ts +++ b/src/entry/content/lifecycle/nodes/isolated-styles.test.ts @@ -22,6 +22,7 @@ test("initializes and returns the styles runtime supplied by the facade", () => const runtime: ContentScriptStylesRuntime = { initialize: jest.fn(), add: jest.fn(), + ready: jest.fn(async () => undefined), delete: jest.fn(), load: jest.fn(), }; diff --git a/src/entry/content/lifecycle/types.ts b/src/entry/content/lifecycle/types.ts new file mode 100644 index 00000000..d6753783 --- /dev/null +++ b/src/entry/content/lifecycle/types.ts @@ -0,0 +1,34 @@ +import type {ContentScriptNode} from "@typing/content"; + +/** One owner drains discovery requests for a single builder generation. */ +export interface ContentScriptCycle { + generation: number; + pending: boolean; + promise: Promise; +} + +/** One completion callback for the event decorator paired with this renderer. */ +export interface ContentScriptMountNotifier { + setMountHandler(handler: () => void): void; +} + +export interface ContentScriptRenderLifecycle extends ContentScriptMountNotifier { + /** The owning lifecycle handles deferred renderer failures without throwing. */ + setErrorHandler(handler: (error: unknown) => void): void; +} + +/** Internal composition result; only renderers supply their own completion signal. */ +export interface ContentScriptNodeAssembly { + node: ContentScriptNode; + renderer?: ContentScriptRenderLifecycle; +} + +export interface ContentScriptRenderOptions { + ready?: () => Promise; +} + +/** The isolation stage supplies a node and optional readiness for its render target. */ +export interface ContentScriptIsolationAssembly { + node: ContentScriptNode; + ready?: () => Promise; +} diff --git a/src/entry/content/resolvers/watch.test.ts b/src/entry/content/resolvers/watch.test.ts index 2dcfee0d..ef9daa3a 100644 --- a/src/entry/content/resolvers/watch.test.ts +++ b/src/entry/content/resolvers/watch.test.ts @@ -1,6 +1,8 @@ import {createAwaitFirstStrategy, createMutationObserverStrategy} from "../../../content"; import {EventEmitter, ManagedContext} from "../lifecycle/context"; import Node from "../lifecycle/nodes/Node"; +import MountNode from "../lifecycle/nodes/MountNode"; +import type {ContentScriptContext} from "@typing/content"; describe("content watch strategies", () => { afterEach(() => { @@ -15,7 +17,7 @@ describe("content watch strategies", () => { body.remove(); try { - const unwatch = createMutationObserverStrategy()(jest.fn(), {} as never); + const unwatch = createMutationObserverStrategy()(jest.fn(), new ManagedContext(new EventEmitter())); expect(observe).toHaveBeenCalledWith( document.documentElement, @@ -66,6 +68,186 @@ describe("content watch strategies", () => { } }); + test("default watching ignores attribute and character-data changes but observes added elements", async () => { + jest.useFakeTimers(); + const element = document.createElement("span"); + element.textContent = "initial"; + document.body.append(element); + const update = jest.fn(); + const context = new ManagedContext(new EventEmitter()); + const unwatch = createMutationObserverStrategy()(update, context); + + try { + element.className = "ready"; + element.firstChild!.nodeValue = "changed"; + await jest.advanceTimersByTimeAsync(201); + expect(update).not.toHaveBeenCalled(); + + element.append(document.createElement("strong")); + await jest.advanceTimersByTimeAsync(201); + expect(update).toHaveBeenCalledTimes(1); + } finally { + unwatch(); + } + }); + + test("processes continuous mutations without waiting for a quiet period", async () => { + jest.useFakeTimers(); + const update = jest.fn(); + const context = new ManagedContext(new EventEmitter()); + const unwatch = createMutationObserverStrategy()(update, context); + + try { + for (let window = 1; window <= 2; window++) { + for (let mutation = 0; mutation < 4; mutation++) { + document.body.append(document.createElement("span")); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(50); + } + + expect(update).toHaveBeenCalledTimes(window); + } + + document.body.append(document.createElement("span")); + await Promise.resolve(); + unwatch(); + await jest.advanceTimersByTimeAsync(201); + expect(update).toHaveBeenCalledTimes(2); + } finally { + unwatch(); + } + }); + + test.each(["owned", "existing", "wrapping"])("filters only exclusive UI in a %s container", async kind => { + jest.useFakeTimers(); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + const text = document.createTextNode("initial"); + container.append(text); + document.body.append(anchor); + + if (kind === "existing") { + document.body.append(container); + } + + const mounter = jest.fn((anchor: Element, container: Element) => { + if (kind === "wrapping") { + anchor.before(container); + container.append(anchor); + } else { + anchor.append(container); + } + }); + + const context = new ManagedContext(new EventEmitter()); + const node = new MountNode(new Node(anchor, container), context.containers, mounter); + context.add(node); + context.mount(); + const update = jest.fn(); + const unwatch = createMutationObserverStrategy({attributes: true, characterData: true})(update, context); + + try { + expect(mounter).toHaveBeenCalledTimes(kind === "existing" ? 0 : 1); + expect(context.owns(container)).toBe(kind === "owned"); + container.append(document.createElement("span")); + container.setAttribute("data-state", "ready"); + text.data = "changed"; + + if (kind === "wrapping") { + anchor.append(document.createElement("strong")); + } + + await jest.advanceTimersByTimeAsync(201); + expect(update).toHaveBeenCalledTimes(kind === "owned" ? 0 : 1); + + unwatch(); + context.unmount(); + expect(context.owns(container)).toBe(false); + + if (kind === "owned") { + context.mount(); + expect(node.container).not.toBe(container); + expect(context.owns(node.container!)).toBe(true); + } + } finally { + unwatch(); + context.clear(); + } + }); + + test("filters owned UI through a wrapped public context", async () => { + jest.useFakeTimers(); + const anchor = document.createElement("article"); + const container = document.createElement("section"); + document.body.append(anchor); + const managedContext = new ManagedContext(new EventEmitter()); + const node = new MountNode(new Node(anchor, container), managedContext.containers, (anchor, container) => + anchor.append(container) + ); + managedContext.add(node); + managedContext.mount(); + + const context: ContentScriptContext = { + get nodes() { + return managedContext.nodes; + }, + owns: target => managedContext.owns(target), + mount: () => managedContext.mount(), + unmount: () => managedContext.unmount(), + watch: callback => managedContext.watch(callback), + unwatch: () => managedContext.unwatch(), + }; + + const update = jest.fn(); + const unwatch = createMutationObserverStrategy()(update, context); + + try { + container.append(document.createElement("span")); + await jest.advanceTimersByTimeAsync(201); + expect(update).not.toHaveBeenCalled(); + document.body.append(document.createElement("article")); + await jest.advanceTimersByTimeAsync(201); + expect(update).toHaveBeenCalledTimes(1); + } finally { + unwatch(); + managedContext.clear(); + } + }); + + test.each(["remove", "move", "mixed"])("observes %s mutations outside an owned container", async kind => { + jest.useFakeTimers(); + const anchor = document.createElement("article"); + const destination = document.createElement("aside"); + document.body.append(anchor, destination); + const container = document.createElement("section"); + const context = new ManagedContext(new EventEmitter()); + const node = new MountNode(new Node(anchor, container), context.containers, (anchor, container) => + anchor.append(container) + ); + context.add(node); + context.mount(); + const update = jest.fn(); + const unwatch = createMutationObserverStrategy()(update, context); + + try { + container.append(document.createElement("span")); + + if (kind === "remove") { + container.remove(); + } else if (kind === "move") { + destination.append(container); + } else { + destination.append(document.createElement("span")); + } + + await jest.advanceTimersByTimeAsync(201); + expect(update).toHaveBeenCalledTimes(1); + } finally { + unwatch(); + context.clear(); + } + }); + test("await-first stops observing once an update adds content nodes", async () => { jest.useFakeTimers(); const context = new ManagedContext(new EventEmitter()); @@ -93,4 +275,36 @@ describe("content watch strategies", () => { context.clear(); } }); + + test("await-first keeps requesting updates during one pending cycle and stops after its completion", async () => { + jest.useFakeTimers(); + const context = new ManagedContext(new EventEmitter()); + const cycle = Promise.withResolvers(); + const completion = cycle.promise.then(() => { + context.add(new Node(document.body)); + }); + const update = jest.fn(() => completion); + const unwatch = createAwaitFirstStrategy()(update, context); + + try { + for (let index = 0; index < 5; index++) { + document.body.append(document.createElement("span")); + await jest.advanceTimersByTimeAsync(201); + } + + expect(update).toHaveBeenCalledTimes(5); + expect(context.nodes.size).toBe(0); + cycle.resolve(); + await jest.advanceTimersByTimeAsync(0); + document.body.append(document.createElement("article")); + await jest.advanceTimersByTimeAsync(201); + expect(update).toHaveBeenCalledTimes(5); + expect(context.nodes.size).toBe(1); + } finally { + cycle.resolve(); + await completion; + unwatch(); + context.clear(); + } + }); }); diff --git a/src/entry/content/resolvers/watch.ts b/src/entry/content/resolvers/watch.ts index 12866d8f..043de5e7 100644 --- a/src/entry/content/resolvers/watch.ts +++ b/src/entry/content/resolvers/watch.ts @@ -1,30 +1,32 @@ -import debounce from "debounce"; - import type {ContentScriptWatchStrategy} from "@typing/content"; -/** Create a debounced DOM-mutation strategy. Calling the returned strategy starts observing. */ +/** Batch DOM mutations in a fixed window. Calling the returned strategy starts observing. */ // prettier-ignore export const createMutationObserverStrategy = (options?: MutationObserverInit): ContentScriptWatchStrategy => - update => { - if (!options) { - options = {}; - } + (update, context) => { + let timer: ReturnType | undefined; - const handle = debounce(update, 200); + const observer = new MutationObserver(records => { + if (timer !== undefined || !records.some(record => !context.owns(record.target))) { + return; + } - const observer = new MutationObserver(handle); + timer = setTimeout(() => { + timer = undefined; + void update(); + }, 200); + }); observer.observe(document.body ?? document.documentElement ?? document, { childList: true, subtree: true, - attributes: true, - characterData: true, ...options, }); return () => { - handle.clear(); + clearTimeout(timer); + timer = undefined; observer.disconnect(); }; }; @@ -41,19 +43,33 @@ export const createAwaitFirstStrategy = }); let unwatch: { (): void } | undefined; + let pending: Promise | undefined; const clear = () => { unwatch && unwatch(); unwatch = undefined; + pending = undefined; }; if (context.nodes.size === 0) { - unwatch = resolver(async () => { - await update(); + unwatch = resolver(() => { + const completion = Promise.resolve(update()); - if (context.nodes.size > 0) { - clear(); + if (completion === pending) { + return completion; } + + pending = completion; + + return completion.then(() => { + if (context.nodes.size > 0) { + clear(); + } + }).finally(() => { + if (pending === completion) { + pending = undefined; + } + }); }, context); } diff --git a/src/entry/relay/index.ts b/src/entry/relay/index.ts index 979d9b5c..74fbf0b8 100644 --- a/src/entry/relay/index.ts +++ b/src/entry/relay/index.ts @@ -6,6 +6,7 @@ import type {ContentScriptIsolation} from "@typing/content"; export {Builder}; export {resolveDefinition} from "./resolvers/definition"; + export type {RelayUnresolvedDefinition} from "@typing/relay"; export default function relay< diff --git a/src/shared/locale/index.ts b/src/shared/locale/index.ts index b3cb55d2..00c373c1 100644 --- a/src/shared/locale/index.ts +++ b/src/shared/locale/index.ts @@ -1,5 +1,5 @@ export {convertLocaleKey, convertLocaleMessageKey, extractLocaleKey, isLocaleKey, modifyLocaleMessageKey} from "./keys"; export {getLocaleDir, isLocaleRtl, resolveLanguage} from "./language"; -export {flattenLocaleMessages, getLocaleFilename} from "./messages"; +export {escapeLocaleMessage, escapeLocaleMessages, flattenLocaleMessages, getLocaleFilename} from "./messages"; export {selectPluralForm} from "./plural"; export {applySubstitutions, parsePlaceholders} from "./substitutions"; diff --git a/src/shared/locale/messages.test.ts b/src/shared/locale/messages.test.ts new file mode 100644 index 00000000..0a8484e0 --- /dev/null +++ b/src/shared/locale/messages.test.ts @@ -0,0 +1,15 @@ +import {escapeLocaleMessage} from "./messages"; + +test.each([ + ["", ""], + ["Hello {{name}}", "Hello {{name}}"], + ["${{value}}/week", "$${{value}}/week"], + ["$ {{value}}/week", "$$ {{value}}/week"], + ["$1/week", "$$1/week"], + ["cost $", "cost $$"], + ["$$", "$$$"], + ["$$$", "$$$$"], + ["$USD$ and ${{value}}", "$$USD$$ and $${{value}}"], +])("escapes native message %j as %j", (message, escaped) => { + expect(escapeLocaleMessage(message)).toBe(escaped); +}); diff --git a/src/shared/locale/messages.ts b/src/shared/locale/messages.ts index 48c992b6..3d79b70e 100644 --- a/src/shared/locale/messages.ts +++ b/src/shared/locale/messages.ts @@ -1,5 +1,23 @@ import type {Language, LocaleMessages} from "@typing/locale"; +/** Native i18n consumes the first dollar in each escaped run; the catalogue keeps the original text. */ +export const escapeLocaleMessage = (message: string): string => message.replace(/\$+/g, dollars => "$" + dollars); + +/** Protects literal $NAME$ text from the earlier named-placeholder pass as well. */ +export const escapeLocaleMessages = (messages: LocaleMessages): LocaleMessages => + Object.fromEntries( + Object.entries(messages).map(([key, value]) => { + const message = escapeLocaleMessage(value.message); + + return [ + key, + /\$[a-z0-9_@]+\$/i.test(value.message) + ? {...value, message: "$text$", placeholders: {text: {content: message}}} + : {...value, message}, + ]; + }) + ); + export const getLocaleFilename = (lang: Language): string => { return `_locales/${lang}/messages.json`; }; diff --git a/src/types/content/common.ts b/src/types/content/common.ts index 1427bbb5..7a6171c0 100644 --- a/src/types/content/common.ts +++ b/src/types/content/common.ts @@ -70,6 +70,8 @@ export interface ContentScriptStylesRuntime { initialize(resolveUrl: (file: string) => string): void; /** Retry restored styles once on load error; future loads keep the default failure behavior. */ add(root: ShadowRoot | HTMLElement, target: Element | null, retry?: boolean): void; + /** Waits for initial and already requested CSS for this root; retries missing failed links. */ + ready(root: ShadowRoot | HTMLElement): Promise; delete(root: ShadowRoot | HTMLElement): void; load(url: string): Promise; } @@ -319,6 +321,13 @@ export interface ContentScriptEventEmitter { export interface ContentScriptContext extends ContentScriptMount { nodes: ReadonlySet; + /** + * Whether target belongs to UI mounted by this context, including ShadowRoot descendants. + * Already-connected site containers and containers wrapping their anchors are excluded. + * Membership ends at unmount. Traversal does not cross an iframe document into its host. + */ + owns(target: Node): boolean; + /** * Registers a callback function that will be invoked when a specific content script context event occurs. * diff --git a/src/types/content/definition.ts b/src/types/content/definition.ts index 69701631..f1b32be8 100644 --- a/src/types/content/definition.ts +++ b/src/types/content/definition.ts @@ -39,6 +39,12 @@ export interface ContentScriptDefinitionBase extends Partial>; prepare?: ContentScriptPrepareHandler; + + /** + * Omit to watch until the first nodes are found; true keeps observing child-list changes. + * Use a custom strategy to observe attributes or text. Built-in strategies ignore internal + * changes in owned UI containers unless a container wraps its own anchor. + */ watch?: true | ContentScriptWatchStrategy; main?: ContentScriptMainFunction; } diff --git a/src/types/locale.ts b/src/types/locale.ts index 764a4271..9e40f266 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -95,6 +95,7 @@ export type LocaleKeys = ReadonlySet; export type LocaleMessages = { [key: string]: { message: string; + placeholders?: Record; }; }; diff --git a/tests/integration/browser/content/isolated-styles-utils.ts b/tests/integration/browser/content/isolated-styles-utils.ts index 6e09453b..6bf1eb1b 100644 --- a/tests/integration/browser/content/isolated-styles-utils.ts +++ b/tests/integration/browser/content/isolated-styles-utils.ts @@ -27,6 +27,7 @@ interface ShadowProbe { readonly links: string[]; readonly ready?: string; readonly sharedCss?: string; + readonly styledFirstRender?: string; } interface ShadowDocumentState { @@ -52,6 +53,7 @@ const documentStateExpression = `(document => { kind: host.getAttribute("data-shadow-probe"), instance: host.getAttribute("data-instance"), closed: host.shadowRoot === null, + styledFirstRender: host.getAttribute("data-styled-first-render"), }; const root = host.shadowRoot ?? host.querySelector("iframe")?.contentDocument; const result = root && root.querySelector("[data-shadow-result]"); @@ -70,6 +72,7 @@ const documentStateExpression = `(document => { ...(result ? Object.fromEntries(Object.entries(result.dataset)) : {ready: "missing"}), instance: host.getAttribute("data-instance") || undefined, kind: host.getAttribute("data-shadow-probe") || undefined, + styledFirstRender: host.getAttribute("data-styled-first-render") || undefined, links: root ? Array.from(root.querySelectorAll('link[rel="stylesheet"]'), link => link.href) : [], }; }); @@ -101,7 +104,12 @@ const isReady = (state: ShadowDocumentState | undefined, expected: number): stat ); }; -const expectDocument = (state: ShadowDocumentState, frame: "top" | "child", primaryCount: number): void => { +const expectDocument = ( + state: ShadowDocumentState, + frame: "top" | "child", + primaryCount: number, + sharedCss: readonly string[] +): void => { const primary = state.probes.filter(probe => probe.kind === "primary"); const secondary = state.probes.filter(probe => probe.kind === "secondary"); @@ -116,8 +124,13 @@ const expectDocument = (state: ShadowDocumentState, frame: "top" | "child", prim expect(probe.initialCss).toBe("applied"); expect(probe.asyncCss).toBe("applied"); expect(probe.sharedCss).toBe("applied"); + expect(probe.styledFirstRender).toBe("true"); expect(probe.links.length).toBeGreaterThanOrEqual(3); expect(probe.links.every(url => /^(chrome|moz)-extension:\/\//.test(url))).toBe(true); + + for (const file of sharedCss) { + expect(probe.links.some(url => new URL(url).pathname === "/" + file)).toBe(true); + } } expect(primary.every(probe => probe.font === "applied")).toBe(true); @@ -130,7 +143,10 @@ export const runIsolatedStylesIntegration = async ( ): Promise => { const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); const binary = name === "chrome" ? findChromeBinary(rootDir) : findFirefoxBinary(); - if (!binary || !path.isAbsolute(binary)) throw new Error("Install " + name + " or set its ADNBN_*_BIN path"); + + if (!binary || !path.isAbsolute(binary)) { + throw new Error("Install " + name + " or set its ADNBN_*_BIN path"); + } const profile = await mkdtemp(path.join(os.tmpdir(), "adnbn-isolation-integration-")); let browserProcess: ChildProcess | undefined; @@ -146,10 +162,12 @@ export const runIsolatedStylesIntegration = async ( const extensionDir = await fixture.build({browser: name, manifestVersion}); const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); const scripts = manifest.content_scripts as Array<{css?: string[]; js: string[]}>; + const resources: string[] = manifestVersion === 2 ? manifest.web_accessible_resources : manifest.web_accessible_resources.flatMap((entry: {resources: string[]}) => entry.resources); + const normal = scripts.find(script => script.js.some(file => file.includes("normal.content"))); const shadows = scripts.filter(script => script !== normal); @@ -157,8 +175,24 @@ export const runIsolatedStylesIntegration = async ( expect(normal).toBeDefined(); expect(shadows).toHaveLength(2); expect(scripts.every(script => script.js.every(file => !/background/i.test(file)))).toBe(true); - const sharedCss = normal!.css!.find(file => resources.includes(file)); - expect(sharedCss).toMatch(/^css\/.+\.[a-f0-9]{8}\.css$/); + // The same emitted stylesheet is shared by document and isolated consumers. + const sharedCss = normal!.css!.filter(file => resources.includes(file)); + expect(sharedCss.length).toBeGreaterThan(0); + + const documentCss = ( + await Promise.all(normal!.css!.map(file => readFile(path.join(extensionDir, file), "utf8"))) + ).join("\n"); + + const isolatedCss = ( + await Promise.all( + resources + .filter(file => file.endsWith(".css")) + .map(file => readFile(path.join(extensionDir, file), "utf8")) + ) + ).join("\n"); + + expect(documentCss).toMatch(/border-top:\s*3px/); + expect(isolatedCss).toMatch(/border-top:\s*3px/); expect(resources.some(file => /^assets\/probe\.[a-f0-9]{8}\.woff2$/.test(file))).toBe(true); expect(resources.some(file => /^css\/.+\.[a-f0-9]{8}\.css$/.test(file))).toBe(true); @@ -188,6 +222,7 @@ export const runIsolatedStylesIntegration = async ( ], {stdio: ["ignore", "ignore", "pipe"]} ); + browserProcess.stderr?.on("data", chunk => (processOutput += chunk)); const {webSocketDebuggerUrl} = await waitFor(() => browserVersion(port)); chrome = await CdpClient.connect(webSocketDebuggerUrl); @@ -199,13 +234,18 @@ export const runIsolatedStylesIntegration = async ( await chrome.send("Runtime.enable", {}, sessionId); await chrome.send("Page.enable", {}, sessionId); navigate = url => chrome!.send("Page.navigate", {url}, sessionId); + evaluate = async expression => { const result = await chrome!.send( "Runtime.evaluate", {expression, awaitPromise: true, returnByValue: true}, sessionId ); - if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + + if (result.exceptionDetails) { + throw new Error(JSON.stringify(result.exceptionDetails)); + } + return result.result.value; }; } else { @@ -222,13 +262,16 @@ export const runIsolatedStylesIntegration = async ( ], {stdio: ["ignore", "ignore", "pipe"]} ); + browserProcess.stderr?.on("data", chunk => (processOutput += chunk)); firefox = await waitFor(() => BidiClient.connect("ws://127.0.0.1:" + port + "/session")); const session = await firefox.send("session.new", {capabilities: {alwaysMatch: {}}}); version = "Firefox/" + session.capabilities.browserVersion; + const extension = await firefox.send("webExtension.install", { extensionData: {path: extensionDir, type: "path"}, }); + expect(typeof extension.extension).toBe("string"); const {context} = await firefox.send("browsingContext.create", {type: "tab"}); navigate = url => firefox!.send("browsingContext.navigate", {context, url, wait: "complete"}); @@ -236,6 +279,7 @@ export const runIsolatedStylesIntegration = async ( } const measurements: Array<{policy: string; top: ShadowDocumentState; frames: FrameState}> = []; + for (const policy of [null, strictCsp]) { site = await startIntegrationSite(path.join(fixture.directory, "site"), policy); const topUrl = site.origin + "/top.html"; @@ -243,37 +287,90 @@ export const runIsolatedStylesIntegration = async ( expect(response.headers.get("content-security-policy")).toBe(policy); await response.arrayBuffer(); await navigate(topUrl); + const top = await waitFor(async () => { const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; + return state?.pageUrl === topUrl && isReady(state, 3) ? state : undefined; }, 30_000); - expectDocument(top, "top", 2); + + expectDocument(top, "top", 2, sharedCss); + + if (fixtureName === "isolation-shadow" && name === "chrome") { + // Firefox BiDi rejects navigation from a web tab to a private extension page. + // Open the actual popup document: the same React component and lazy CSS + // must work without isolated-style delivery in an extension page. + const stylesheet = top.probes.find(probe => probe.kind === "primary")!.links[0]; + const popupPath = manifest.action?.default_popup ?? manifest.browser_action?.default_popup; + expect(popupPath).toBeDefined(); + await navigate(new URL("/" + popupPath, stylesheet).href); + + await waitFor( + async () => { + const ready = await evaluate(`(() => { + const panel = document.querySelector('[data-shadow-result="primary"]'); + return panel?.dataset.ready === "true" && + getComputedStyle(panel).color === "rgb(17, 85, 153)" && + getComputedStyle(panel).backgroundColor === "rgb(34, 102, 68)"; + })()`); + + return ready || undefined; + }, + 30_000, + "shared React component and lazy stylesheet in popup" + ); + + expect( + await evaluate( + `getComputedStyle(document.documentElement).getPropertyValue("--adnbn-cascade-order").trim()` + ) + ).toBe("default"); + + await navigate(topUrl); + + await waitFor( + async () => { + const state = await evaluate(`${documentStateExpression}(document)`); + + return isReady(state, 3) ? state : undefined; + }, + 30_000, + "content styles after popup reuse" + ); + } if (fixtureName === "isolation-shadow") { expect(top.probes.filter(probe => probe.kind === "primary").every(probe => probe.mode === "open")).toBe( true ); + expect(top.probes.find(probe => probe.kind === "secondary")).toMatchObject({ mode: "closed", closed: true, }); + const instance = Number(top.probes.find(probe => probe.kind === "secondary")!.instance); + await evaluate(`(() => { document.querySelector('[data-shadow-secondary]')?.remove(); const anchor = document.createElement("div"); anchor.setAttribute("data-shadow-secondary", ""); document.body.appendChild(anchor); })()`); + const replaced = await waitFor(async () => { const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; + return isReady(state, 3) && Number(state.probes.find(probe => probe.kind === "secondary")?.instance) > instance ? state : undefined; }, 30_000); - expectDocument(replaced, "top", 2); + + expectDocument(replaced, "top", 2, sharedCss); + expect(replaced.probes.find(probe => probe.kind === "secondary")).toMatchObject({ mode: "closed", closed: true, @@ -282,6 +379,7 @@ export const runIsolatedStylesIntegration = async ( if (fixtureName === "isolation-iframe") { let mounts = top.probes.map(probe => probe.mounts!); + for (const operation of ["append", "insert", "reinsert"]) { await evaluate(`(() => { const destination = document.createElement("section"); @@ -292,47 +390,59 @@ export const runIsolatedStylesIntegration = async ( else destination.appendChild(host); } })()`); + const recovered = await waitFor(async () => { const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; + return isReady(state, 3) && state.probes.every(probe => probe.mounts! > Math.max(...mounts)) ? state : undefined; }, 10000); - expectDocument(recovered, "top", 2); + + expectDocument(recovered, "top", 2, sharedCss); expect(recovered.probes.every(probe => probe.mounts === Math.max(...mounts) + 1)).toBe(true); mounts = recovered.probes.map(probe => probe.mounts!); } } const oldInstance = Number(top.probes.find(probe => probe.anchor === "first")?.instance); + await evaluate(`(() => { document.querySelector('[data-shadow-primary="first"]')?.remove(); const replacement = document.createElement("div"); replacement.setAttribute("data-shadow-primary", "first"); document.body.append(replacement); })()`); + const remounted = await waitFor(async () => { const state = await evaluate(`${documentStateExpression}(document)`); lastState = state; const instance = Number(state?.probes.find(probe => probe.anchor === "first")?.instance); + return isReady(state, 3) && instance > oldInstance ? state : undefined; }, 30_000); - expectDocument(remounted, "top", 2); + + expectDocument(remounted, "top", 2, sharedCss); const framesUrl = site.origin + "/frames.html"; await navigate(framesUrl); + const frames = await waitFor(async () => { const state = await evaluate(`(() => { const child = document.querySelector('[data-testid="child-frame"]')?.contentDocument; if (!child || child.readyState !== "complete") return undefined; return {top: ${documentStateExpression}(document), child: ${documentStateExpression}(child)}; })()`); + lastState = state; + return isReady(state?.top, 2) && isReady(state?.child, 2) ? state : undefined; }, 30_000); - expectDocument(frames.top, "top", 1); - expectDocument(frames.child, "child", 1); + + expectDocument(frames.top, "top", 1, sharedCss); + expectDocument(frames.child, "child", 1, sharedCss); + if (fixtureName === "isolation-shadow") { for (const document of [frames.top, frames.child]) { expect(document.probes.find(probe => probe.kind === "secondary")).toMatchObject({ @@ -341,18 +451,21 @@ export const runIsolatedStylesIntegration = async ( }); } } + measurements.push({policy: policy === null ? "none" : "strict", top: remounted, frames}); await site.close(); site = undefined; } const report = {browser: version, manifestVersion, measurements}; + const reportPath = path.join( rootDir, ".cache", "integration", fixtureName + "-" + name + "-mv" + manifestVersion + ".json" ); + await writeFile(reportPath, JSON.stringify(report, null, 2) + "\n"); console.info(JSON.stringify(report, null, 2)); expect(version).toMatch(new RegExp(`^${name === "chrome" ? "Chrome" : "Firefox"}\\/\\d+(?:\\.\\d+)+$`)); @@ -364,15 +477,21 @@ export const runIsolatedStylesIntegration = async ( ); } finally { await chrome?.close(); + if (firefox) { try { await firefox.send("session.end", {}, 2_000); } catch { // Firefox may close before replying. } + await firefox.close(); } - if (browserProcess) await stop(browserProcess); + + if (browserProcess) { + await stop(browserProcess); + } + await site?.close(); await fixture?.dispose(); await rm(profile, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); diff --git a/tests/integration/browser/content/isolation-iframe/README.md b/tests/integration/browser/content/isolation-iframe/README.md index d270aef9..e6e4ef13 100644 --- a/tests/integration/browser/content/isolation-iframe/README.md +++ b/tests/integration/browser/content/isolation-iframe/README.md @@ -4,7 +4,7 @@ This fixture uses the production `isolation: Iframe` API with a React primary en secondary entry and an ordinary content entry. Shared CSS verifies manifest and WAR delivery to both kinds of consumers. Each isolated entry has its own style registry. -UI CSS uses `?isolation`. The local font is declared with `@font-face` in iframe CSS, with no +Ordinary UI CSS goes to the iframe document by default. The local font is declared with `@font-face` in iframe CSS, with no FontFace registration in JavaScript. Ordinary CSS remains in the host document. The site directory contains the receiving page and child frame, not extension entrypoints. Tests diff --git a/tests/integration/browser/content/isolation-iframe/src/normal.content.ts b/tests/integration/browser/content/isolation-iframe/src/normal.content.ts index 8843b8de..4c160a63 100644 --- a/tests/integration/browser/content/isolation-iframe/src/normal.content.ts +++ b/tests/integration/browser/content/isolation-iframe/src/normal.content.ts @@ -1,6 +1,6 @@ import {defineContentScript} from "adnbn"; -import sharedStyles from "./shared/styles.module.css?isolation"; +import sharedStyles from "./shared/styles.module.css"; export default defineContentScript({ matches: ["http://127.0.0.1/*"], diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx b/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx index 2cdf2e16..6d51fea0 100644 --- a/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/Panel.tsx @@ -1,28 +1,36 @@ import React, {useEffect, useRef} from "react"; -import sharedStyles from "../shared/styles.module.css?isolation"; -import styles from "./styles.module.css?isolation"; +import sharedStyles from "../shared/styles.module.css"; +import styles from "./styles.module.css"; // Also proves that importing before the first iframe exists remembers its async stylesheet. const lazyModule = import("./lazy"); export function Panel({anchor}: {anchor: Element}) { const ref = useRef(null); + useEffect(() => { const root = ref.current!; let active = true; root.dataset.ready = "false"; root.dataset.frame = window === window.top ? "top" : "child"; root.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; + void (async () => { const lazy = await lazyModule; - if (!active) return; + + if (!active) { + return; + } + lazy.applyLazyStyle(root); const doc = root.ownerDocument; const started = performance.now(); + while (active) { const range = doc.createRange(); range.selectNodeContents(root.querySelector("span")!); const style = doc.defaultView!.getComputedStyle(root); + root.dataset.measurement = JSON.stringify({ color: style.color, background: style.backgroundColor, @@ -30,6 +38,7 @@ export function Panel({anchor}: {anchor: Element}) { width: range.getBoundingClientRect().width, fonts: Array.from(doc.fonts, font => ({family: font.family, status: font.status})), }); + if ( style.color === "rgb(17, 85, 153)" && style.backgroundColor === "rgb(34, 102, 68)" && @@ -43,18 +52,27 @@ export function Panel({anchor}: {anchor: Element}) { sharedCss: "applied", font: "applied", }); + return; } - if (performance.now() - started > 10000) throw new Error("Iframe CSS/font did not apply"); + + if (performance.now() - started > 10000) { + throw new Error("Iframe CSS/font did not apply"); + } + await new Promise(resolve => setTimeout(resolve, 20)); } })().catch(error => { - if (active) root.dataset.error = String(error); + if (active) { + root.dataset.error = String(error); + } }); + return () => { active = false; }; }, [anchor]); + return (
AAAA diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx b/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx index 05cc9c93..fcffdbe6 100644 --- a/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/index.tsx @@ -3,7 +3,7 @@ import React from "react"; import {Panel} from "./Panel"; import {ContentScriptIsolation, ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; -import "./fonts.css?isolation&asis"; +import "./fonts.css?asis"; let instance = 0; @@ -25,5 +25,10 @@ export default defineContentScriptAppend({ return host; }, - render: ({anchor}) => , + render: ({anchor, container, target}) => { + const links = Array.from(target.ownerDocument.head.querySelectorAll('link[rel="stylesheet"]')); + container.setAttribute("data-styled-first-render", String(links.length > 0 && links.every(link => link.sheet))); + + return ; + }, }); diff --git a/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts b/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts index b127d013..469fe45a 100644 --- a/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts +++ b/tests/integration/browser/content/isolation-iframe/src/probe.content/lazy.ts @@ -1,4 +1,4 @@ -import styles from "./lazy.module.css?isolation"; +import styles from "./lazy.module.css"; export const applyLazyStyle = (element: HTMLElement): void => { element.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts index ebe91acc..6146654a 100644 --- a/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts +++ b/tests/integration/browser/content/isolation-iframe/src/secondary.content/index.ts @@ -2,8 +2,8 @@ import {observeMounts} from "../shared/events"; import {ContentScriptIsolation, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; import {createAwaitFirstStrategy} from "adnbn/content"; -import sharedStyles from "../shared/styles.module.css?isolation"; -import styles from "./styles.module.css?isolation"; +import sharedStyles from "../shared/styles.module.css"; +import styles from "./styles.module.css"; export default defineContentScriptAppend({ main: observeMounts, @@ -19,7 +19,9 @@ export default defineContentScriptAppend({ return host; }, - render() { + render({container, target}) { + const links = Array.from(target.ownerDocument.head.querySelectorAll('link[rel="stylesheet"]')); + container.setAttribute("data-styled-first-render", String(links.length > 0 && links.every(link => link.sheet))); const root = document.createElement("div"); root.classList.add(styles.root, sharedStyles.shared); root.dataset.shadowResult = "secondary"; diff --git a/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts b/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts index b127d013..469fe45a 100644 --- a/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts +++ b/tests/integration/browser/content/isolation-iframe/src/secondary.content/lazy.ts @@ -1,4 +1,4 @@ -import styles from "./lazy.module.css?isolation"; +import styles from "./lazy.module.css"; export const applyLazyStyle = (element: HTMLElement): void => { element.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/isolation-shadow/README.md b/tests/integration/browser/content/isolation-shadow/README.md index f9c7aec0..812a9368 100644 --- a/tests/integration/browser/content/isolation-shadow/README.md +++ b/tests/integration/browser/content/isolation-shadow/README.md @@ -1,6 +1,6 @@ # Shadow DOM content integration -This fixture exercises the production `isolation: Shadow` content-entrypoint API with the native Vanilla +This fixture exercises the production `isolation: Shadow` content-entrypoint API with the native React and Vanilla renderer. It contains two shadow entrypoints and one ordinary entrypoint. All three consume a forced shared CSS-only chunk, while each shadow entrypoint also loads its own initial and lazy styles. @@ -11,8 +11,10 @@ continues to load through `content_scripts.css` outside Shadow DOM. ## What the runner verifies -- Plain document CSS remains in the manifest, including the font declaration. Initial, lazy and shared - `?isolation` CSS files are in web accessible resources and delivered only to the selected roots. +- Explicit `?unisolated` CSS remains in the manifest, including the font declaration. Ordinary initial, lazy and shared + UI styles are in web accessible resources and delivered only to the selected roots. +- In Chrome, the same React component and lazy SCSS also render in the popup document without special imports. + Firefox builds the same popup but BiDi does not allow direct navigation to its private extension URL. - The ordinary entrypoint retains manifest-level CSS, including the shared chunk. - No content entrypoint receives a background file. - Every root contains file-backed extension `` elements and receives initial and requested lazy diff --git a/tests/integration/browser/content/isolation-shadow/package.json b/tests/integration/browser/content/isolation-shadow/package.json index d5359099..f10e47ad 100644 --- a/tests/integration/browser/content/isolation-shadow/package.json +++ b/tests/integration/browser/content/isolation-shadow/package.json @@ -4,6 +4,11 @@ "type": "module", "version": "1.0.0", "dependencies": { - "adnbn": "file:../../../../.." + "adnbn": "file:../../../../..", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "scheduler": "*" } } diff --git a/tests/integration/browser/content/isolation-shadow/src/normal.content.ts b/tests/integration/browser/content/isolation-shadow/src/normal.content.ts index 8843b8de..4c160a63 100644 --- a/tests/integration/browser/content/isolation-shadow/src/normal.content.ts +++ b/tests/integration/browser/content/isolation-shadow/src/normal.content.ts @@ -1,6 +1,6 @@ import {defineContentScript} from "adnbn"; -import sharedStyles from "./shared/styles.module.css?isolation"; +import sharedStyles from "./shared/styles.module.css"; export default defineContentScript({ matches: ["http://127.0.0.1/*"], diff --git a/tests/integration/browser/content/isolation-shadow/src/popup.tsx b/tests/integration/browser/content/isolation-shadow/src/popup.tsx new file mode 100644 index 00000000..1cd94450 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/popup.tsx @@ -0,0 +1,8 @@ +import React from "react"; +import {definePopup} from "adnbn"; +import "./probe.content/fonts.css?unisolated&asis"; +import {Panel} from "./shared/panel/Panel"; + +export default definePopup({ + render: () => , +}); diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css b/tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css index 937d9f42..67c83073 100644 --- a/tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/fonts.css @@ -4,3 +4,7 @@ font-weight: 400; font-style: normal; } + +:root { + --adnbn-cascade-order: document; +} diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts deleted file mode 100644 index 7306fbac..00000000 --- a/tests/integration/browser/content/isolation-shadow/src/probe.content/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {ContentScriptIsolation, ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; - -import "./fonts.css?asis"; -import sharedStyles from "../shared/styles.module.css?isolation"; -import styles from "./styles.module.css?isolation"; - -const asyncModule = import("./lazy"); -const expectedWidth = 320; -let instance = 0; - -const width = (element: Element): number => { - const range = document.createRange(); - range.selectNodeContents(element); - - return range.getBoundingClientRect().width; -}; - -const waitForStyles = async (element: HTMLElement, predicate: () => boolean): Promise => { - const started = performance.now(); - - while (!predicate()) { - if (performance.now() - started > 10_000) { - throw new Error("Shadow styles did not apply within 10 seconds"); - } - - await new Promise(resolve => requestAnimationFrame(() => resolve())); - } - - element.dataset.ready = "true"; -}; - -export default defineContentScriptAppend({ - matches: ["http://127.0.0.1/*"], - world: ContentScriptWorld.Isolated, - runAt: "document_end", - allFrames: true, - anchor: "[data-shadow-primary]", - append: ContentScriptAppend.Last, - watch: true, - isolation: ContentScriptIsolation.Shadow, - container: ({anchor}) => { - const host = document.createElement("section"); - host.dataset.shadowProbe = "primary"; - host.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; - host.dataset.instance = String(++instance); - - return host; - }, - render({anchor}) { - const root = document.createElement("div"); - root.classList.add(styles.root, sharedStyles.shared); - root.dataset.shadowResult = "primary"; - root.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; - root.dataset.frame = window === window.top ? "top" : "child"; - root.dataset.ready = "false"; - - const font = document.createElement("span"); - font.className = styles.font; - font.textContent = "AAAA"; - root.append(font); - - void asyncModule - .then(module => { - module.applyLazyStyle(root); - }) - .then(() => - waitForStyles( - root, - () => - getComputedStyle(root).color === "rgb(17, 85, 153)" && - getComputedStyle(root).backgroundColor === "rgb(34, 102, 68)" && - getComputedStyle(root).borderTopWidth === "3px" && - Math.abs(width(font) - expectedWidth) < 0.1 - ) - ) - .then(() => { - root.dataset.initialCss = "applied"; - root.dataset.asyncCss = "applied"; - root.dataset.sharedCss = "applied"; - root.dataset.font = "applied"; - }) - .catch(cause => { - root.dataset.error = cause instanceof Error ? cause.message : String(cause); - root.dataset.ready = "error"; - }); - - return root; - }, -}); diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/index.tsx b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.tsx new file mode 100644 index 00000000..f27f785c --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/probe.content/index.tsx @@ -0,0 +1,43 @@ +import {ContentScriptIsolation, ContentScriptAppend, ContentScriptWorld, defineContentScriptAppend} from "adnbn"; +import "./fonts.css?unisolated&asis"; +import {Panel} from "../shared/panel/Panel"; + +let instance = 0; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + world: ContentScriptWorld.Isolated, + runAt: "document_end", + allFrames: true, + anchor: "[data-shadow-primary]", + append: ContentScriptAppend.Last, + watch: true, + isolation: ContentScriptIsolation.Shadow, + boundary: ({boundary, container}) => { + const observer = new MutationObserver(records => { + if (!records.some(record => record.target.parentNode === boundary && record.addedNodes.length)) { + return; + } + + const links = Array.from(boundary.querySelectorAll('link[rel="stylesheet"]')); + container.setAttribute( + "data-styled-first-render", + String(links.length > 0 && links.every(link => link.sheet)) + ); + observer.disconnect(); + }); + + observer.observe(boundary, {childList: true, subtree: true}); + + return () => observer.disconnect(); + }, + container: ({anchor}) => { + const host = document.createElement("section"); + host.dataset.shadowProbe = "primary"; + host.dataset.anchor = anchor.getAttribute("data-shadow-primary") ?? "unknown"; + host.dataset.instance = String(++instance); + + return host; + }, + render: Panel, +}); diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.module.css b/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.module.css deleted file mode 100644 index 3aa1605c..00000000 --- a/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.loaded { - background-color: rgb(34, 102, 68); -} diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/styles.module.css b/tests/integration/browser/content/isolation-shadow/src/probe.content/styles.module.css deleted file mode 100644 index e2b9cebf..00000000 --- a/tests/integration/browser/content/isolation-shadow/src/probe.content/styles.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.root { - color: rgb(17, 85, 153); -} - -.font { - display: inline-block; - font-family: "AdnbnShadowProbe", monospace; - font-size: 40px; - font-kerning: none; - font-variant-ligatures: none; - letter-spacing: 0; - white-space: pre; -} diff --git a/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts index 99645037..9a78a7aa 100644 --- a/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/index.ts @@ -1,8 +1,8 @@ import {ContentScriptIsolation, ContentScriptShadowMode, ContentScriptAppend, defineContentScriptAppend} from "adnbn"; import {createMutationObserverStrategy} from "adnbn/content"; -import sharedStyles from "../shared/styles.module.css?isolation"; -import styles from "./styles.module.css?isolation"; +import sharedStyles from "../shared/styles.module.css"; +import styles from "./styles.module.css"; let instance = 0; @@ -20,7 +20,11 @@ export default defineContentScriptAppend({ return host; }, - render() { + render({container, target}) { + const links = Array.from( + (target.getRootNode() as ShadowRoot).querySelectorAll('link[rel="stylesheet"]') + ); + container.setAttribute("data-styled-first-render", String(links.length > 0 && links.every(link => link.sheet))); const root = document.createElement("div"); root.classList.add(styles.root, sharedStyles.shared); root.dataset.shadowResult = "secondary"; @@ -53,8 +57,13 @@ export default defineContentScriptAppend({ // results from inside the fixture, without exposing the root or changing production code. const report = (element: HTMLElement): void => { const root = element.getRootNode() as ShadowRoot; - if (!root.host) return; + + if (!root.host) { + return; + } + const style = element.ownerDocument.defaultView!.getComputedStyle(element); + (root.host as HTMLElement).dataset.shadowReport = JSON.stringify({ ...element.dataset, mode: root.mode, diff --git a/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts b/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts index b127d013..469fe45a 100644 --- a/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts +++ b/tests/integration/browser/content/isolation-shadow/src/secondary.content/lazy.ts @@ -1,4 +1,4 @@ -import styles from "./lazy.module.css?isolation"; +import styles from "./lazy.module.css"; export const applyLazyStyle = (element: HTMLElement): void => { element.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/isolation-shadow/src/shared/panel/Panel.tsx b/tests/integration/browser/content/isolation-shadow/src/shared/panel/Panel.tsx new file mode 100644 index 00000000..e0b42f0f --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/shared/panel/Panel.tsx @@ -0,0 +1,81 @@ +import React, {useEffect, useRef} from "react"; +import sharedStyles from "../styles.module.css"; +import styles from "./styles.module.scss"; + +// Also proves that importing before the first root exists remembers its async stylesheet. +const lazyModule = import("./lazy"); + +export function Panel({anchor}: {anchor?: Element}) { + const ref = useRef(null); + + useEffect(() => { + const root = ref.current!; + let active = true; + root.dataset.ready = "false"; + root.dataset.frame = window === window.top ? "top" : "child"; + root.dataset.anchor = anchor?.getAttribute("data-shadow-primary") ?? "unknown"; + + void (async () => { + const lazy = await lazyModule; + + if (!active) { + return; + } + + lazy.applyLazyStyle(root); + const doc = root.ownerDocument; + const started = performance.now(); + + while (active) { + const range = doc.createRange(); + range.selectNodeContents(root.querySelector("span")!); + const style = doc.defaultView!.getComputedStyle(root); + + root.dataset.measurement = JSON.stringify({ + color: style.color, + background: style.backgroundColor, + border: style.borderTopWidth, + width: range.getBoundingClientRect().width, + fonts: Array.from(doc.fonts, font => ({family: font.family, status: font.status})), + }); + + if ( + style.color === "rgb(17, 85, 153)" && + style.backgroundColor === "rgb(34, 102, 68)" && + style.borderTopWidth === "3px" && + Math.abs(range.getBoundingClientRect().width - 320) < 0.1 + ) { + Object.assign(root.dataset, { + ready: "true", + initialCss: "applied", + asyncCss: "applied", + sharedCss: "applied", + font: "applied", + }); + + return; + } + + if (performance.now() - started > 10000) { + throw new Error("Shared React CSS/font did not apply"); + } + + await new Promise(resolve => setTimeout(resolve, 20)); + } + })().catch(error => { + if (active) { + root.dataset.error = String(error); + } + }); + + return () => { + active = false; + }; + }, [anchor]); + + return ( +
+ AAAA +
+ ); +} diff --git a/tests/integration/browser/content/isolation-shadow/src/shared/panel/lazy.module.scss b/tests/integration/browser/content/isolation-shadow/src/shared/panel/lazy.module.scss new file mode 100644 index 00000000..b02ee9ae --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/shared/panel/lazy.module.scss @@ -0,0 +1,3 @@ +.loaded { + background-color: rgb(34, 102, 68); +} diff --git a/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.ts b/tests/integration/browser/content/isolation-shadow/src/shared/panel/lazy.ts similarity index 68% rename from tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.ts rename to tests/integration/browser/content/isolation-shadow/src/shared/panel/lazy.ts index b127d013..f0dff38e 100644 --- a/tests/integration/browser/content/isolation-shadow/src/probe.content/lazy.ts +++ b/tests/integration/browser/content/isolation-shadow/src/shared/panel/lazy.ts @@ -1,4 +1,4 @@ -import styles from "./lazy.module.css?isolation"; +import styles from "./lazy.module.scss"; export const applyLazyStyle = (element: HTMLElement): void => { element.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/isolation-shadow/src/shared/panel/styles.module.scss b/tests/integration/browser/content/isolation-shadow/src/shared/panel/styles.module.scss new file mode 100644 index 00000000..78d03308 --- /dev/null +++ b/tests/integration/browser/content/isolation-shadow/src/shared/panel/styles.module.scss @@ -0,0 +1,17 @@ +.root { + color: rgb(17, 85, 153); +} + +:global(:root) { + --adnbn-cascade-order: default; +} + +.font { + display: inline-block; + font-family: "AdnbnShadowProbe", monospace; + font-size: 40px; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: 0; + white-space: pre; +} diff --git a/tests/integration/browser/content/isolation-shadow/tsconfig.json b/tests/integration/browser/content/isolation-shadow/tsconfig.json index eadf1be8..0be5b764 100644 --- a/tests/integration/browser/content/isolation-shadow/tsconfig.json +++ b/tests/integration/browser/content/isolation-shadow/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "./.adnbn/tsconfig.json" + "extends": "./.adnbn/tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + } } diff --git a/tests/integration/browser/content/relay-styles/README.md b/tests/integration/browser/content/relay-styles/README.md index 5a3359f1..21f2d06e 100644 --- a/tests/integration/browser/content/relay-styles/README.md +++ b/tests/integration/browser/content/relay-styles/README.md @@ -1,8 +1,8 @@ # Relay style routing Two native Relay panels share a component and use Shadow/blank iframe rendering. An extension page -imports that same component (where `?isolation` is inert). Background calls both Relays through Messaging RPC. -Each call imports lazy document and UI CSS and checks their actual application before returning. +imports that same component (where all CSS loads normally). Background calls both Relays through Messaging RPC. +Document styles use `?unisolated`; UI styles use ordinary imports. Each call imports lazy document and UI CSS and checks their actual application before returning. The Shadow Relay uses `isolation: {type: Shadow, mode: Closed}`. Its RPC result includes measurements from the closed root and verifies that `host.shadowRoot` is `null`; rendering and lazy styles still work. A third Relay embeds this page through `isolation.page`, exercising the navigation adapter and WAR check. diff --git a/tests/integration/browser/content/relay-styles/src/shared/lazy.ts b/tests/integration/browser/content/relay-styles/src/shared/lazy.ts index b3960dae..6a0a4972 100644 --- a/tests/integration/browser/content/relay-styles/src/shared/lazy.ts +++ b/tests/integration/browser/content/relay-styles/src/shared/lazy.ts @@ -1,3 +1,3 @@ -import "./host-lazy.css?asis"; -import styles from "./lazy.module.css?isolation"; +import "./host-lazy.css?unisolated&asis"; +import styles from "./lazy.module.css"; export const apply = (panel: HTMLElement) => panel.classList.add(styles.loaded); diff --git a/tests/integration/browser/content/relay-styles/src/shared/panel.ts b/tests/integration/browser/content/relay-styles/src/shared/panel.ts index a8d908f7..533992b5 100644 --- a/tests/integration/browser/content/relay-styles/src/shared/panel.ts +++ b/tests/integration/browser/content/relay-styles/src/shared/panel.ts @@ -1,11 +1,12 @@ -import "./host.css?asis"; -import styles from "./panel.module.css?isolation"; +import "./host.css?unisolated&asis"; +import styles from "./panel.module.css"; export const createPanel = (kind: string) => { const panel = document.createElement("section"); panel.dataset.relayPanel = kind; panel.className = styles.panel; panel.textContent = kind; + return panel; }; @@ -13,11 +14,16 @@ export const createApi = (getPanel: () => HTMLElement | undefined) => ({ ready: () => !!getPanel()?.isConnected, async load() { const panel = getPanel(); - if (!panel) throw new Error("Panel is not mounted"); + + if (!panel) { + throw new Error("Panel is not mounted"); + } + const lazy = await import("./lazy"); lazy.apply(panel); const view = panel.ownerDocument.defaultView!; const root = panel.getRootNode(); + return { color: view.getComputedStyle(panel).color, background: view.getComputedStyle(panel).backgroundColor, diff --git a/tests/integration/browser/content/render-props-utils.ts b/tests/integration/browser/content/render-props-utils.ts index e518575c..5c4a5ba3 100644 --- a/tests/integration/browser/content/render-props-utils.ts +++ b/tests/integration/browser/content/render-props-utils.ts @@ -61,11 +61,23 @@ export async function expectRenderProps(browser: "chrome" | "firefox") { const snapshot = (id: string) => session!.evaluate(`JSON.parse(document.getElementById(${JSON.stringify(id)}).dataset.snapshot)`); - const command = (id: string, command: string) => - session!.evaluate( - `document.dispatchEvent(new CustomEvent('probe-command', {detail: ${JSON.stringify(JSON.stringify({id, command}))}}))` + const command = async (id: string, command: string) => { + await session!.evaluate(`(() => { + document.getElementById(${JSON.stringify(id)}).removeAttribute('data-command'); + document.dispatchEvent(new CustomEvent('probe-command', {detail: ${JSON.stringify(JSON.stringify({id, command}))}})); + })()`); + + await until( + async () => + await session!.evaluate( + `document.getElementById(${JSON.stringify(id)}).dataset.command === ${JSON.stringify(command)} || document.body.dataset.error` + ), + `${id} to finish ${command}` ); + expect(await session!.evaluate("document.body.dataset.error ?? null")).toBeNull(); + }; + const shadow = await snapshot("shadow"); expect(shadow).toMatchObject({ @@ -141,8 +153,16 @@ export async function expectRenderProps(browser: "chrome" | "firefox") { expect((await snapshot("shadow")).targetId).toBe(shadow.targetId); await command("shadow", "remount"); + await until( + async () => + await session!.evaluate( + "JSON.parse(document.getElementById('shadow').dataset.lifecycle).events.includes('mount')" + ), + "Shadow DOM Mount event after CSS readiness" + ); + expect(await session.evaluate("JSON.parse(document.getElementById('shadow').dataset.lifecycle)")).toMatchObject( - {events: ["unmount", "mount"], mounted: true, connected: true} + {events: ["unmount", "mount"], mounted: false, connected: true} ); await until(async () => (await snapshot("shadow")).targetId !== shadow.targetId, "new Shadow DOM target"); diff --git a/tests/integration/browser/content/render-props/src/shared/lifecycle.ts b/tests/integration/browser/content/render-props/src/shared/lifecycle.ts index e516e4a5..24abc32c 100644 --- a/tests/integration/browser/content/render-props/src/shared/lifecycle.ts +++ b/tests/integration/browser/content/render-props/src/shared/lifecycle.ts @@ -2,7 +2,12 @@ import type {ContentScriptContext} from "adnbn"; export function main(context: ContentScriptContext) { const events: string[] = []; - context.watch(event => events.push(event)); + let update: (() => void) | undefined; + + context.watch(event => { + events.push(event); + update?.(); + }); document.addEventListener("probe-command", event => { const {id, command} = JSON.parse((event as CustomEvent).detail) as {id: string; command: string}; @@ -13,6 +18,7 @@ export function main(context: ContentScriptContext) { } try { + update = undefined; events.length = 0; let mounted: ReturnType; @@ -44,10 +50,19 @@ export function main(context: ContentScriptContext) { node.anchor.setAttribute("data-tracked", String(context.nodes.size)); node.anchor.setAttribute("data-command", command); - node.anchor.setAttribute( - "data-lifecycle", - JSON.stringify({events, mounted, connected: node.target?.isConnected, text: node.target?.textContent}) - ); + update = () => { + node.anchor.setAttribute( + "data-lifecycle", + JSON.stringify({ + events, + mounted, + connected: node.target?.isConnected, + text: node.target?.textContent, + }) + ); + }; + + update(); } catch (error) { document.body.dataset.error = String(error); } diff --git a/tests/integration/browser/content/selective-split-utils.ts b/tests/integration/browser/content/selective-split-utils.ts new file mode 100644 index 00000000..53152c44 --- /dev/null +++ b/tests/integration/browser/content/selective-split-utils.ts @@ -0,0 +1,226 @@ +import {copyFile, mkdir, mkdtemp, readFile, rm, writeFile} from "fs/promises"; +import path from "path"; + +import { + closeCompiler, + createCompiler, + orders, + project, + runCompiler, +} from "../../build/style-routing/selective-split/compiler"; +import {waitFor} from "../utils/browser"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite} from "../utils/site"; + +interface SelectiveSplitProbe { + name: string; + state?: string; + error?: string; + completion?: {order: string; color: string; border: string}; + color: string; + border: string; + links: string[]; +} + +const stateExpression = `['normal', 'shadow'].map(name => { + const host = document.querySelector('#selective-' + name); + + if (!host) { + return {name}; + } + + const root = host.shadowRoot || host; + const style = getComputedStyle(root.querySelector('.cascade-probe')); + + return { + name, + state: host.dataset.state, + error: host.dataset.error, + completion: host.dataset.completion ? JSON.parse(host.dataset.completion) : undefined, + color: style.color, + border: style.borderTopWidth, + links: Array.from(root.querySelectorAll('link'), link => new URL(link.href).pathname.slice(1)) + }; +})`; + +export const runSelectiveSplitProbe = async (browser: "chrome" | "firefox", manifestVersion: 2 | 3) => { + const cache = path.join(project, ".cache/integration"); + await mkdir(cache, {recursive: true}); + const extension = await mkdtemp(path.join(cache, "selective-split-")); + let session: Awaited> | undefined; + const measurements: unknown[] = []; + let state: SelectiveSplitProbe[] = []; + + try { + const compiler = await createCompiler(extension, true, {initial: true}); + const stats = await runCompiler(compiler).finally(() => closeCompiler(compiler)); + const scripts = ["normal", "shadow"].flatMap(entry => + stats.compilation.entrypoints + .get(entry)! + .getFiles() + .filter(file => file.endsWith(".js")) + ); + + const initialScripts = orders.map(order => ({ + matches: [`http://127.0.0.1/${order}.html`], + css: stats.compilation.entrypoints + .get(`content-${order}`)! + .getFiles() + .filter(file => file.endsWith(".css")), + run_at: "document_start", + })); + const resources = stats.compilation.getAssets().map(asset => asset.name); + + await writeFile( + path.join(extension, "manifest.json"), + JSON.stringify({ + manifest_version: manifestVersion, + name: "Selective CSS split probe", + version: "1.0.0", + ...(browser === "firefox" + ? {browser_specific_settings: {gecko: {id: "selective-split@adnbn.test"}}} + : {}), + content_scripts: [ + {matches: ["http://127.0.0.1/top.html*"], js: scripts, run_at: "document_end"}, + ...initialScripts, + ], + web_accessible_resources: + manifestVersion === 2 ? resources : [{resources, matches: ["http://127.0.0.1/*"]}], + }) + ); + + session = await startBrowserSession(browser, project, extension); + const sourceSite = path.join(__dirname, "../../build/style-routing/selective-split/site"); + const siteDirectory = path.join(extension, "site"); + await mkdir(siteDirectory); + await copyFile(path.join(sourceSite, "top.html"), path.join(siteDirectory, "top.html")); + + for (const order of orders) { + await copyFile(path.join(sourceSite, "initial.html"), path.join(siteDirectory, `${order}.html`)); + } + + const site = await startIntegrationSite(siteDirectory, null); + + try { + for (const order of orders) { + for (const first of ["normal", "shadow"] as const) { + await session.navigate(`${site.origin}/top.html?order=${order}`); + await waitFor(async () => { + const buttons = await session!.evaluate("document.querySelectorAll('button').length"); + + return buttons === 2 ? true : undefined; + }, 10_000); + + expect(await session.evaluate("document.querySelectorAll('link').length")).toBe(0); + expect( + await session.evaluate( + "document.querySelector('#selective-shadow').shadowRoot.querySelectorAll('link').length" + ) + ).toBe(0); + + for (const name of [first, first === "normal" ? "shadow" : "normal"]) { + await session.evaluate(`document.querySelector('#load-${name}').click()`); + const loaded = await waitFor(async () => { + state = await session!.evaluate(stateExpression); + const probe = state.find(probe => probe.name === name)!; + + if (probe.state === "error") { + throw new Error(probe.error); + } + + return probe.state === "loaded" ? probe : undefined; + }, 10_000); + + // Recorded inside the import continuation: the Promise must wait for applied CSS. + expect(loaded.completion).toEqual({ + order, + color: "rgb(0, 0, 204)", + border: name === "normal" ? "7px" : "0px", + }); + } + + state = await session.evaluate(stateExpression); + const documentLinks: string[] = await session.evaluate( + "Array.from(document.head.querySelectorAll('link'), link => new URL(link.href).pathname.slice(1))" + ); + + const documentCss = await Promise.all( + documentLinks.map(file => readFile(path.join(extension, file), "utf8")) + ); + + expect(documentCss).toHaveLength(2); + expect(documentCss[0]).toContain("--split-document:"); + expect(documentCss[0]).not.toContain("--split-ui:"); + expect(documentCss[1]).toContain("--split-ui:"); + expect(documentCss[1]).not.toContain("--split-document:"); + const shadow = state.find(probe => probe.name === "shadow")!; + expect(shadow.links).toEqual([documentLinks[1]]); + expect(state.map(probe => ({name: probe.name, color: probe.color, border: probe.border}))).toEqual([ + {name: "normal", color: "rgb(0, 0, 204)", border: "7px"}, + {name: "shadow", color: "rgb(0, 0, 204)", border: "0px"}, + ]); + + measurements.push({order, first, state, documentLinks}); + } + } + + for (const order of orders) { + await session.navigate(`${site.origin}/${order}.html`); + const color = order === "document-first" ? "rgb(0, 0, 204)" : "rgb(204, 0, 0)"; + const initial = await waitFor( + async () => { + const result = await session!.evaluate(`(() => { + const probe = document.querySelector('.cascade-probe'); + + if (!probe) { + return; + } + + const style = getComputedStyle(probe); + + return {color: style.color, border: style.borderTopWidth}; + })()`); + + return result?.color === color ? result : undefined; + }, + 10_000, + `initial content CSS cascade for ${order}` + ); + expect(initial).toEqual({color, border: "7px"}); + measurements.push({initial: order, ...initial}); + } + + expect(session.errors).toEqual([]); + } finally { + await site.close(); + } + } catch (error) { + throw new Error( + `${String(error)}; last state: ${JSON.stringify(state)}; browser errors: ${JSON.stringify(session?.errors)}`, + {cause: error} + ); + } finally { + try { + await writeFile( + path.join(cache, `selective-split-${browser}-mv${manifestVersion}.json`), + JSON.stringify( + { + browser: session?.version, + manifestVersion, + measurements, + lastState: state, + errors: session?.errors, + }, + null, + 2 + ) + ); + } finally { + try { + await session?.close(); + } finally { + await rm(extension, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + } + } +}; diff --git a/tests/integration/browser/content/selective-split.firefox.integration.test.ts b/tests/integration/browser/content/selective-split.firefox.integration.test.ts new file mode 100644 index 00000000..eb613308 --- /dev/null +++ b/tests/integration/browser/content/selective-split.firefox.integration.test.ts @@ -0,0 +1,10 @@ +import {runSelectiveSplitProbe} from "./selective-split-utils"; + +jest.setTimeout(90_000); + +test.each([2, 3] as const)( + "Firefox MV%s keeps the shared lazy CSS pair in chunk order for normal and shadow consumers", + async version => { + await runSelectiveSplitProbe("firefox", version); + } +); diff --git a/tests/integration/browser/content/selective-split.integration.test.ts b/tests/integration/browser/content/selective-split.integration.test.ts new file mode 100644 index 00000000..f7bd3bff --- /dev/null +++ b/tests/integration/browser/content/selective-split.integration.test.ts @@ -0,0 +1,7 @@ +import {runSelectiveSplitProbe} from "./selective-split-utils"; + +jest.setTimeout(90_000); + +test("Chrome MV3 loads the shared lazy CSS pair in chunk order and keeps document styles outside ShadowRoot", async () => { + await runSelectiveSplitProbe("chrome", 3); +}); diff --git a/tests/integration/browser/locale/dollars.firefox.integration.test.ts b/tests/integration/browser/locale/dollars.firefox.integration.test.ts new file mode 100644 index 00000000..c2ebab9f --- /dev/null +++ b/tests/integration/browser/locale/dollars.firefox.integration.test.ts @@ -0,0 +1,7 @@ +import {verifyLocaleDollars} from "./dollars"; + +jest.setTimeout(90_000); + +test("Firefox preserves literal dollars in native and dynamic YAML translations", async () => { + await verifyLocaleDollars("firefox"); +}); diff --git a/tests/integration/browser/locale/dollars.integration.test.ts b/tests/integration/browser/locale/dollars.integration.test.ts new file mode 100644 index 00000000..911abc1e --- /dev/null +++ b/tests/integration/browser/locale/dollars.integration.test.ts @@ -0,0 +1,7 @@ +import {verifyLocaleDollars} from "./dollars"; + +jest.setTimeout(90_000); + +test("Chrome preserves literal dollars in native and dynamic YAML translations", async () => { + await verifyLocaleDollars("chrome"); +}); diff --git a/tests/integration/browser/locale/dollars.ts b/tests/integration/browser/locale/dollars.ts new file mode 100644 index 00000000..14d13587 --- /dev/null +++ b/tests/integration/browser/locale/dollars.ts @@ -0,0 +1,52 @@ +import path from "path"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {waitFor} from "../utils/browser"; +import {startBrowserSession} from "../utils/session"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; + +export const verifyLocaleDollars = async (browser: "chrome" | "firefox"): Promise => { + const fixture = await createIntegrationFixture( + ADNBN_TEST_ROOT, + path.join(ADNBN_TEST_ROOT, "tests/integration/build/locale/dollars-fixture") + ); + let session: Awaited> | undefined; + let site: IntegrationSite | undefined; + + try { + const directory = await fixture.build({browser}); + site = await startIntegrationSite(path.join(fixture.directory, "site")); + session = await startBrowserSession(browser, ADNBN_TEST_ROOT, directory); + await session.navigate(site.origin); + + const result = await waitFor( + () => session!.evaluate('document.getElementById("locale-dollar-results")?.textContent'), + 15_000, + "native and dynamic dollar translations" + ); + const expected = { + price: "$1.15/week", + spaced: "$ 1.15/week", + number: "$1/week", + trailing: "cost $", + single: "$", + double: "$$", + triple: "$$$", + named: "$USD$", + substitution: "$1.15/week", + plural: "$1.15 for 2 weeks", + }; + + expect(JSON.parse(result)).toMatchObject({ + native: expected, + dynamic: expected, + }); + expect(session.errors).toEqual([]); + } finally { + try { + await session?.close(); + } finally { + await site?.close(); + await fixture.dispose(); + } + } +}; diff --git a/tests/integration/build/content/content-api.integration.test.ts b/tests/integration/build/content/content-api.integration.test.ts index 44445910..e8ac8a29 100644 --- a/tests/integration/build/content/content-api.integration.test.ts +++ b/tests/integration/build/content/content-api.integration.test.ts @@ -39,8 +39,11 @@ describe("published content API", () => { const inputs = Object.keys(result.metafile!.inputs).map(filename => filename.replaceAll("\\", "/")); expect(inputs.some(filename => filename.includes("entry/content/adapters/"))).toBe(false); expect(inputs.some(filename => /node_modules\/(react|react-dom)\//.test(filename))).toBe(false); + if (entry === "adnbn/content") { expect(inputs).toContain("dist/content/index.js"); + + // Watchers depend only on the public context contract, not lifecycle implementations. expect(inputs.some(filename => filename.includes("entry/content/lifecycle/"))).toBe(false); } }); diff --git a/tests/integration/build/isolation/isolation-watch.integration.test.ts b/tests/integration/build/isolation/isolation-watch.integration.test.ts index 3e5c8efd..cfdef26c 100644 --- a/tests/integration/build/isolation/isolation-watch.integration.test.ts +++ b/tests/integration/build/isolation/isolation-watch.integration.test.ts @@ -1,5 +1,5 @@ import {spawn, type ChildProcess} from "child_process"; -import {copyFile, readFile, rm, writeFile} from "fs/promises"; +import {cp, copyFile, readFile, rm, writeFile} from "fs/promises"; import path from "path"; import vm from "vm"; import {createIntegrationFixture} from "../../utils/fixture"; @@ -9,67 +9,102 @@ jest.setTimeout(90_000); test("real CLI watch changes isolation and page aliases and removes outdated WAR matches", async () => { const root = path.resolve(__dirname, "../../../.."); + const fixture = await createIntegrationFixture( root, path.join(root, "tests/integration/browser/content/frame-pages") ); + let watcher: ChildProcess | undefined; let output = ""; + try { await copyFile(path.join(__dirname, "states/background.ts"), path.join(fixture.directory, "src/background.ts")); await rm(path.join(fixture.directory, "src/source.content.ts")); + await copyFile( path.join(root, "tests/integration/browser/content/isolation-shadow/src/probe.content/probe.woff2"), path.join(fixture.directory, "src/probe.woff2") ); + await writeFile(path.join(fixture.directory, "src/watch.css"), ".watch-ui { color: red; }\n"); await copyFile(path.join(__dirname, "states/fonts.css"), path.join(fixture.directory, "src/watch-fonts.css")); + await cp(path.join(__dirname, "states/shared-lazy"), path.join(fixture.directory, "src/shared-lazy"), { + recursive: true, + }); + await copyFile( + path.join(__dirname, "states/normal-shared.ts"), + path.join(fixture.directory, "src/normal.content.ts") + ); const entry = path.join(fixture.directory, "src/page.content.ts"); // Save like an editor: Windows copyFile preserves the fixture's old mtime, so watch can miss the edit. const setState = async (state: string) => writeFile(entry, await readFile(path.join(__dirname, "states", state + ".ts"))); - await setState("page-isolated"); - await expect(fixture.build({browser: "chrome"})).rejects.toThrow( - "uses ?isolation CSS with isolation.page/isolation.src, but has no local render target" - ); + await setState("none"); const directory = await fixture.build({browser: "chrome"}); + const readPages = async () => { const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); const source = await readFile(path.join(directory, manifest.background.service_worker), "utf8"); const sandbox = {readPages: undefined as undefined | (() => unknown)}; vm.runInNewContext(source, sandbox); + return sandbox.readPages!(); }; + expect(await readPages()).toEqual({panel: "panel.html"}); await rm(path.join(directory, "manifest.json")); + watcher = spawn(process.execPath, [path.join(root, "bin/adnbn.js"), "watch", ".", "-b", "chrome"], { cwd: fixture.directory, stdio: ["ignore", "pipe", "pipe"], }); + watcher.stdout?.on("data", chunk => { output += chunk; }); + watcher.stderr?.on("data", chunk => { output += chunk; }); - const observe = (state: string) => + + const completedBuilds = () => + (output.replace(/\x1b\[[0-9;]*m/g, "").match(/Rspack [^\n]* compiled successfully/g) ?? []).length; + + const observe = (state: string, after = 0) => waitFor( async () => { + // Watch rewrites stable filenames: a new JS file can precede its manifest. + // Only inspect a generation after the CLI reports that compilation is complete. + if (completedBuilds() <= after) { + return undefined; + } + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const script = manifest.content_scripts.find((script: {js: string[]}) => script.js.some(file => /page\.content/.test(file)) ); - if (!script) return undefined; + + if (!script) { + return undefined; + } + const source = ( await Promise.all(script.js.map((file: string) => readFile(path.join(directory, file), "utf8"))) ).join("\n"); - if (!source.includes(JSON.stringify("isolation-watch-" + state))) return undefined; + + if (!source.includes(JSON.stringify("isolation-watch-" + state))) { + return undefined; + } + return {manifest, script, source}; }, 15000, `CLI watch state "${state}"` ); + for (const state of [ "none", "shadow", @@ -81,57 +116,109 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR "iframe", "page", "source", + "none-shared", + "shadow-shared", + "none-shared", "none", ]) { - const beforeBuild = output.length; - if (state !== "none" || output) await setState(state); - const {manifest, script, source} = await observe(state); - if (state === "shadow-document") { - await waitFor( - async () => output.slice(beforeBuild).includes("[adnbn:missing-isolation-css]") || undefined, - 15000, - "CLI watch missing-isolation-css warning" - ); + const after = completedBuilds(); + + if (state !== "none" || output) { + await setState(state); } - const isolated = ["shadow", "shadow-closed", "shadow-document", "iframe", "fonts"].includes(state); - expect(!!script.css?.length).toBe(!isolated || state === "shadow-document"); + + const {manifest, script, source} = await observe(state, after); + const isolated = [ + "shadow", + "shadow-closed", + "shadow-document", + "shadow-shared", + "iframe", + "fonts", + ].includes(state); + expect({state, documentCss: !!script.css?.length}).toEqual({ + state, + documentCss: !isolated || state === "shadow-document", + }); + expect( /__webpack_require__(?:\.__adnbnIsolatedStyles|\["__adnbnIsolatedStyles"\])\s*=\s*\{/.test(source) ).toBe(isolated); + expect(script.js.some((file: string) => /background/.test(file))).toBe(false); + const resources = manifest.web_accessible_resources.flatMap( (rule: {resources: string[]}) => rule.resources ); + expect(resources.some((file: string) => file.endsWith(".woff2"))).toBe(state === "fonts"); + + if (state.endsWith("-shared")) { + const css = await Promise.all( + [...new Set(resources.filter((file: string) => file.endsWith(".css")))].map(async file => ({ + file, + source: await readFile(path.join(directory, file), "utf8"), + })) + ); + const lazy = css.filter(({source}) => source.includes("--watch-lazy-")); + expect(lazy).toHaveLength(isolated ? 2 : 1); + + if (isolated) { + expect(lazy.filter(({source}) => source.includes("--watch-lazy-ui:"))).toHaveLength(1); + expect(lazy.filter(({source}) => source.includes("--watch-lazy-document:"))).toHaveLength(1); + expect( + lazy.every( + ({source}) => + !(source.includes("--watch-lazy-ui:") && source.includes("--watch-lazy-document:")) + ) + ).toBe(true); + } else { + expect(lazy[0].source).toContain("--watch-lazy-ui:"); + expect(lazy[0].source).toContain("--watch-lazy-document:"); + } + + expect(source.includes("loadDocumentStylesheet")).toBe(isolated); + } } // Start broad, then narrow: the old WAR rule must disappear, not accumulate in the builder. const page = path.join(fixture.directory, "src/panel.page/index.ts"); const pageSource = await readFile(page, "utf8"); + await writeFile( page, pageSource.replace('name: "panel"', 'name: "renamed-panel"').replace("http://127.0.0.1/*", "http://*/*") ); + await setState("renamed"); const renamed = await observe("renamed"); expect(renamed.source).toContain("renamed-panel"); + await waitFor( async () => { const pages = await readPages(); expect(pages).toEqual({"renamed-panel": "panel.html"}); + return pages; }, 15000, "CLI watch to refresh page data without editing its background consumer" ); + const compiled = output; await writeFile(page, pageSource.replace('name: "panel"', 'name: "renamed-panel"')); + await waitFor( async () => { - if (output === compiled) return undefined; + if (output === compiled) { + return undefined; + } + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const pageRules = manifest.web_accessible_resources.filter((rule: {resources: string[]}) => rule.resources.some(file => file.endsWith(".html")) ); + return pageRules.length && pageRules.every((rule: {matches: string[]}) => !rule.matches.includes("http://*/*")) ? true @@ -144,27 +231,33 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR // Validation is late, but an invalid build must not replace the last valid manifest or stop watch. const validManifest = await readFile(path.join(directory, "manifest.json"), "utf8"); const beforeFailure = output.length; + await writeFile( page, pageSource .replace('name: "panel"', 'name: "renamed-panel"') .replace("http://127.0.0.1/*", "https://unrelated.example/*") ); + await waitFor( async () => output.slice(beforeFailure).includes("add matches to the page") || undefined, 15000, "CLI watch page access validation error" ); + expect(await readFile(path.join(directory, "manifest.json"), "utf8")).toBe(validManifest); + await writeFile( page, pageSource .replace('name: "panel"', 'name: "renamed-panel"') .replace("http://127.0.0.1/*", "*://127.0.0.1/*") ); + await waitFor( async () => { const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + return manifest.web_accessible_resources.some( (rule: {resources: string[]; matches: string[]}) => rule.resources.some(file => file.endsWith(".html")) && rule.matches.includes("*://127.0.0.1/*") @@ -178,7 +271,10 @@ test("real CLI watch changes isolation and page aliases and removes outdated WAR } catch (error) { throw new Error(`${String(error)}\n${output}`, {cause: error}); } finally { - if (watcher) await stop(watcher); + if (watcher) { + await stop(watcher); + } + await fixture.dispose(); } }); diff --git a/tests/integration/build/isolation/states/fonts.ts b/tests/integration/build/isolation/states/fonts.ts index 86137d40..6f27ab03 100644 --- a/tests/integration/build/isolation/states/fonts.ts +++ b/tests/integration/build/isolation/states/fonts.ts @@ -1,6 +1,7 @@ import {defineContentScriptAppend} from "adnbn"; -import "./watch.css?isolation"; -import "./watch-fonts.css?isolation"; +import "./watch.css"; +import "./watch-fonts.css"; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], isolation: "iframe", diff --git a/tests/integration/build/isolation/states/iframe.ts b/tests/integration/build/isolation/states/iframe.ts index 06084e8a..5ffb5d15 100644 --- a/tests/integration/build/isolation/states/iframe.ts +++ b/tests/integration/build/isolation/states/iframe.ts @@ -1,5 +1,6 @@ import {defineContentScriptAppend} from "adnbn"; -import "./watch.css?isolation"; +import "./watch.css"; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], isolation: {type: "iframe"}, diff --git a/tests/integration/build/isolation/states/none-shared.ts b/tests/integration/build/isolation/states/none-shared.ts new file mode 100644 index 00000000..673889bc --- /dev/null +++ b/tests/integration/build/isolation/states/none-shared.ts @@ -0,0 +1,13 @@ +import {defineContentScriptAppend} from "adnbn"; +import {loadShared} from "./shared-lazy/loader"; +import "./watch.css"; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + render: "Watch UI", + main() { + console.info("isolation-watch-none-shared"); + + return loadShared(); + }, +}); diff --git a/tests/integration/build/isolation/states/normal-shared.ts b/tests/integration/build/isolation/states/normal-shared.ts new file mode 100644 index 00000000..30b60308 --- /dev/null +++ b/tests/integration/build/isolation/states/normal-shared.ts @@ -0,0 +1,7 @@ +import {defineContentScript} from "adnbn"; +import {loadShared} from "./shared-lazy/loader"; + +export default defineContentScript({ + matches: ["https://example.com/*"], + main: loadShared, +}); diff --git a/tests/integration/build/isolation/states/page-isolated.ts b/tests/integration/build/isolation/states/page-isolated.ts deleted file mode 100644 index 10cf03bd..00000000 --- a/tests/integration/build/isolation/states/page-isolated.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {defineContentScriptAppend} from "adnbn"; -import "./watch.css?isolation"; - -export default defineContentScriptAppend({ - matches: ["http://127.0.0.1/*"], - isolation: {type: "iframe", page: "panel"}, -}); diff --git a/tests/integration/build/isolation/states/shadow-closed.ts b/tests/integration/build/isolation/states/shadow-closed.ts index 1fd2407a..16391146 100644 --- a/tests/integration/build/isolation/states/shadow-closed.ts +++ b/tests/integration/build/isolation/states/shadow-closed.ts @@ -1,5 +1,6 @@ import {ContentScriptIsolation, ContentScriptShadowMode, defineContentScriptAppend} from "adnbn"; -import "./watch.css?isolation"; +import "./watch.css"; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], isolation: {type: ContentScriptIsolation.Shadow, mode: ContentScriptShadowMode.Closed}, diff --git a/tests/integration/build/isolation/states/shadow-document.ts b/tests/integration/build/isolation/states/shadow-document.ts index 52b4f3b1..c9a02c8f 100644 --- a/tests/integration/build/isolation/states/shadow-document.ts +++ b/tests/integration/build/isolation/states/shadow-document.ts @@ -1,5 +1,6 @@ import {defineContentScriptAppend} from "adnbn"; -import "./watch.css"; +import "./watch.css?unisolated"; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], isolation: "shadow", diff --git a/tests/integration/build/isolation/states/shadow-shared.ts b/tests/integration/build/isolation/states/shadow-shared.ts new file mode 100644 index 00000000..9638d5a3 --- /dev/null +++ b/tests/integration/build/isolation/states/shadow-shared.ts @@ -0,0 +1,14 @@ +import {defineContentScriptAppend} from "adnbn"; +import {loadShared} from "./shared-lazy/loader"; +import "./watch.css"; + +export default defineContentScriptAppend({ + matches: ["http://127.0.0.1/*"], + isolation: "shadow", + render: "Watch UI", + main() { + console.info("isolation-watch-shadow-shared"); + + return loadShared(); + }, +}); diff --git a/tests/integration/build/isolation/states/shadow.ts b/tests/integration/build/isolation/states/shadow.ts index 1660dbc5..8787a86c 100644 --- a/tests/integration/build/isolation/states/shadow.ts +++ b/tests/integration/build/isolation/states/shadow.ts @@ -1,5 +1,6 @@ import {defineContentScriptAppend} from "adnbn"; -import "./watch.css?isolation"; +import "./watch.css"; + export default defineContentScriptAppend({ matches: ["http://127.0.0.1/*"], isolation: "shadow", diff --git a/tests/integration/build/isolation/states/shared-lazy/document.scss b/tests/integration/build/isolation/states/shared-lazy/document.scss new file mode 100644 index 00000000..6f8bebfe --- /dev/null +++ b/tests/integration/build/isolation/states/shared-lazy/document.scss @@ -0,0 +1,4 @@ +.watch-lazy { + --watch-lazy-document: true; + color: red; +} diff --git a/tests/integration/build/isolation/states/shared-lazy/loader.ts b/tests/integration/build/isolation/states/shared-lazy/loader.ts new file mode 100644 index 00000000..10001313 --- /dev/null +++ b/tests/integration/build/isolation/states/shared-lazy/loader.ts @@ -0,0 +1 @@ +export const loadShared = () => import("./panel"); diff --git a/tests/integration/build/isolation/states/shared-lazy/panel.scss b/tests/integration/build/isolation/states/shared-lazy/panel.scss new file mode 100644 index 00000000..2a83410a --- /dev/null +++ b/tests/integration/build/isolation/states/shared-lazy/panel.scss @@ -0,0 +1,4 @@ +.watch-lazy { + --watch-lazy-ui: true; + color: blue; +} diff --git a/tests/integration/build/isolation/states/shared-lazy/panel.ts b/tests/integration/build/isolation/states/shared-lazy/panel.ts new file mode 100644 index 00000000..b9ebe787 --- /dev/null +++ b/tests/integration/build/isolation/states/shared-lazy/panel.ts @@ -0,0 +1,4 @@ +import "./panel.scss"; +import "./document.scss?unisolated"; + +export const label = "Shared lazy panel"; diff --git a/tests/integration/build/locale/dollars-fixture/adnbn.config.ts b/tests/integration/build/locale/dollars-fixture/adnbn.config.ts new file mode 100644 index 00000000..d51e5f5f --- /dev/null +++ b/tests/integration/build/locale/dollars-fixture/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Locale dollar test", + version: "1.0.0", + specific: {gecko: {id: "dollar-locale@adnbn.test"}}, +}); diff --git a/tests/integration/build/locale/dollars-fixture/package.json b/tests/integration/build/locale/dollars-fixture/package.json new file mode 100644 index 00000000..506f2672 --- /dev/null +++ b/tests/integration/build/locale/dollars-fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-locale-dollars", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "*" + } +} diff --git a/tests/integration/build/locale/dollars-fixture/site/top.html b/tests/integration/build/locale/dollars-fixture/site/top.html new file mode 100644 index 00000000..bebce94f --- /dev/null +++ b/tests/integration/build/locale/dollars-fixture/site/top.html @@ -0,0 +1,7 @@ + + + + Locale dollar test + + + diff --git a/tests/integration/build/locale/dollars-fixture/src/dollars.content.ts b/tests/integration/build/locale/dollars-fixture/src/dollars.content.ts new file mode 100644 index 00000000..55fe592e --- /dev/null +++ b/tests/integration/build/locale/dollars-fixture/src/dollars.content.ts @@ -0,0 +1,29 @@ +import {defineContentScript} from "adnbn"; +import {DynamicLocale, NativeLocale, type LocaleProvider, type LocaleRegistry} from "adnbn/locale"; + +const translate = (locale: LocaleProvider) => ({ + price: locale.trans("price", {value: "1.15"}), + spaced: locale.trans("spaced", {value: "1.15"}), + number: locale.trans("number"), + trailing: locale.trans("trailing"), + single: locale.trans("single"), + double: locale.trans("double"), + triple: locale.trans("triple"), + named: locale.trans("named"), + substitution: locale.trans("substitution", {value: "$1.15"}), + plural: locale.choice("plural", 2, {value: "1.15", count: 2}), +}); + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + render: () => { + const result = document.createElement("pre"); + result.id = "locale-dollar-results"; + result.textContent = JSON.stringify({ + native: translate(new NativeLocale()), + dynamic: translate(new DynamicLocale(false)), + }); + + return result; + }, +}); diff --git a/tests/integration/build/locale/dollars-fixture/src/locales/en.yaml b/tests/integration/build/locale/dollars-fixture/src/locales/en.yaml new file mode 100644 index 00000000..814744d6 --- /dev/null +++ b/tests/integration/build/locale/dollars-fixture/src/locales/en.yaml @@ -0,0 +1,12 @@ +price: "${{value}}/week" +spaced: "$ {{value}}/week" +number: "$1/week" +trailing: "cost $" +single: "$" +double: "$$" +triple: "$$$" +named: "$USD$" +substitution: "{{value}}/week" +plural: + - "${{value}} for {{count}} week" + - "${{value}} for {{count}} weeks" diff --git a/tests/integration/build/locale/dollars-fixture/tsconfig.json b/tests/integration/build/locale/dollars-fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/locale/dollars-fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/locale/locale.integration.test.ts b/tests/integration/build/locale/locale.integration.test.ts index 46ab96fd..f8b3d446 100644 --- a/tests/integration/build/locale/locale.integration.test.ts +++ b/tests/integration/build/locale/locale.integration.test.ts @@ -4,6 +4,7 @@ import path from "path"; import vm from "vm"; import {createIntegrationFixture} from "../../utils/fixture"; import {stop, waitFor} from "../../browser/utils/browser"; +import {escapeLocaleMessages} from "@shared/locale"; jest.setTimeout(90_000); @@ -33,10 +34,10 @@ const inspect = async (directory: string) => { expect(Object.keys(catalogue).sort()).toEqual(languages.sort()); for (const lang of languages) { const messages = JSON.parse(await readFile(path.join(directory, "_locales", lang, "messages.json"), "utf8")); - const flattened = Object.fromEntries( - Object.entries(messages).map(([key, value]) => [key, (value as {message: string}).message]) + const native = escapeLocaleMessages( + Object.fromEntries(Object.entries(catalogue[lang]).map(([key, message]) => [key, {message}])) ); - expect(catalogue[lang]).toEqual(flattened); + expect(messages).toEqual(native); expect(catalogue[lang].locale).toBe(lang); } return {catalogue, keys: sandbox.readLocaleKeys!(), lang, languages: exportedLanguages}; diff --git a/tests/integration/build/style-routing/README.md b/tests/integration/build/style-routing/README.md index 817a9532..85d22461 100644 --- a/tests/integration/build/style-routing/README.md +++ b/tests/integration/build/style-routing/README.md @@ -15,13 +15,28 @@ entry chunks, browser-specific CSS URLs, binary assets and inline exclusions. Sm files in `src/resources` are build fixtures, not usable fonts or media; real WOFF2 rendering is checked by the browser fixtures. -The config resolver supplies the style plugin's `isolationIssuerLayer` condition from the -shared build-layer contract used by content/Relay entries. The style plugin does not identify -entrypoint types itself. Production routing checks cover both execution-world layers, an -unrelated custom issuer selector, and no selector (all CSS stays on the ordinary path). +JavaScript uses one layer per execution world, independently of UI isolation. The style plugin +classifies default CSS and `?unisolated` CSS without inspecting JavaScript issuer layers. +The content plugin selects isolated delivery per entry; other entries load both categories in +their document. The same default CSS file can belong to an ordinary content manifest and to WAR +for Shadow/iframe consumers at the same time. +Tests include nested SCSS with Sass dependencies, normal content/popup reuse, the same CSS in both +destinations with contenthash-only names, and canonical runtime asset maps. Checks cover both execution-world layers, +an unrelated custom layer, and disabled isolated delivery. + +`delivery-spike.integration.test.ts` now runs the production style rules. It checks one JavaScript +module per world for user components, React, adnbn and a direct `@addon-core/browser` import, without +package exceptions. Emitted JavaScript runs against simulated DOM loading events to verify mixed +lazy CSS waits, independent entry runtimes, late roots and watch changes without new JS identities. +`content-chunks.integration.test.ts` checks sharing with `commonChunks` both enabled and disabled. + +`selective-split.integration.test.ts` now exercises the production entry-selective split. It covers +ordinary initial CSS names and import order, legal mixed chunks, isolated manifest/WAR routing and +watch selection changes with a shared lazy chunk. Matching browser suites measure initial cascade +and the shared-lazy boundary in real documents and ShadowRoots. See [selective-split/README.md](selective-split/README.md). ```sh -npx jest --runTestsByPath tests/integration/build/style-routing/routing-spike.integration.test.ts tests/integration/build/style-routing/style-routing.integration.test.ts --runInBand +node scripts/test-run.mjs --selectProjects build --runTestsByPath tests/integration/build/style-routing/routing-spike.integration.test.ts tests/integration/build/style-routing/style-routing.integration.test.ts --runInBand ``` Production lifecycle and browser coverage lives in `browser/content/isolation-shadow`, `isolation-iframe`, diff --git a/tests/integration/build/style-routing/content-chunks.integration.test.ts b/tests/integration/build/style-routing/content-chunks.integration.test.ts new file mode 100644 index 00000000..39acc8fa --- /dev/null +++ b/tests/integration/build/style-routing/content-chunks.integration.test.ts @@ -0,0 +1,170 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import {createRequire} from "module"; +import {rspack, type Configuration, type NormalModule, type Stats} from "@rspack/core"; +import {merge} from "webpack-merge"; +import stylePlugin from "@cli/plugins/style"; +import reactPlugin from "@cli/plugins/react"; +import optimizationPlugin from "@cli/plugins/optimization"; +import {getContentLayer, DefaultStylesLayer} from "@cli/bundler/layers"; +import {GenerateModulePlugin} from "@cli/bundler/plugins/generate-module"; +import {createRuntimeModule} from "@cli/bundler/plugins/utils"; +import {RuntimeModuleRequest, RuntimeModuleReaders} from "@cli/plugins/output/runtime"; +import IsolatedStylesPlugin from "@cli/bundler/plugins/isolated-styles"; +import {ContentScriptStylesRuntimeProperty, ContentScriptWorld} from "@typing/content"; +import type {ReadonlyConfig} from "@typing/config"; + +const root = path.resolve(__dirname, "../../../.."); +const require = createRequire(import.meta.url); + +test.each([true, false])( + "content runtime modules share a world without sharing CSS policy (commonChunks=%s)", + async commonChunks => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-content-chunks-")); + const config = { + rootDir: root, + app: "test", + commonChunks, + mergeStyles: false, + cssDir: "css", + cssFilename: "[name].[contenthash:8].css", + cssIdentName: "[local]", + } as ReadonlyConfig; + + const settings: Configuration[] = []; + + for (const plugin of [stylePlugin(), reactPlugin(), optimizationPlugin()]) { + const handler = plugin.bundler!; + settings.push( + (typeof handler === "function" ? await handler({config, rspack: {}}) : handler) as Configuration + ); + } + + const worldLayer = getContentLayer(ContentScriptWorld.Isolated); + const mainLayer = getContentLayer(ContentScriptWorld.Main); + const compiler = rspack( + merge(merge(settings), { + context: path.join(__dirname, "src"), + mode: "production", + devtool: false, + entry: { + normal: {import: "./runtime-libraries.js", layer: worldLayer}, + shadow: {import: "./runtime-libraries.js", layer: worldLayer}, + iframe: {import: "./runtime-libraries.js", layer: worldLayer}, + relay: {import: "./runtime-libraries.js", layer: worldLayer}, + main: {import: "./runtime-libraries.js", layer: mainLayer, asyncChunks: false}, + popup: "./runtime-libraries.js", + background: "./background-unused.js", + }, + output: {path: output, filename: "js/[name].[contenthash:8].js", publicPath: ""}, + resolve: { + alias: { + adnbn$: path.join(root, "dist/index.js"), + "adnbn/entry/content/vanilla$": path.join(root, "dist/entry/content/adapters/vanilla/index.js"), + }, + modules: [path.join(__dirname, "src/vendor"), "node_modules"], + }, + resolveLoader: {modules: [path.join(root, "node_modules")]}, + optimization: {minimize: false, concatenateModules: false}, + plugins: [ + new IsolatedStylesPlugin({ + test: entry => ["shadow", "iframe", "relay"].includes(entry), + property: ContentScriptStylesRuntimeProperty, + cssFilename: "css/[name].[contenthash:8].css", + cssChunkFilename: "css/[name].[contenthash:8].css", + }), + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), + ], + } satisfies Configuration) + ); + + try { + const stats = await new Promise((resolve, reject) => + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + resolve(stats); + } + }) + ); + + const {compilation} = stats; + const modules = [...compilation.modules] as NormalModule[]; + const layers = (resource: string) => + modules.filter(module => module.resource === resource).map(module => module.layer ?? null); + + for (const resource of [ + require.resolve("react"), + require.resolve("react-dom/client"), + require.resolve("scheduler"), + path.join(root, "node_modules/@addon-core/browser/dist/index.js"), + path.join(root, "dist/entry/content/lifecycle/Builder.js"), + ]) { + expect(layers(resource).sort()).toEqual([worldLayer, mainLayer, null].sort()); + } + + // User components and CSS-bearing libraries share JavaScript identity within the world too. + expect(layers(path.join(__dirname, "src/shared.js")).sort()).toEqual([worldLayer, mainLayer, null].sort()); + expect(layers(path.join(__dirname, "src/vendor/fixture-styles/index.js")).sort()).toEqual( + [worldLayer, mainLayer, null].sort() + ); + + const builder = modules.find( + module => + module.resource === path.join(root, "dist/entry/content/lifecycle/Builder.js") && + module.layer === worldLayer + )!; + const builderChunks = compilation.chunkGraph.getModuleChunks(builder); + + if (commonChunks) { + expect(builderChunks).toHaveLength(1); + + for (const entry of ["normal", "shadow", "iframe", "relay"]) { + expect(compilation.entrypoints.get(entry)!.chunks).toContain(builderChunks[0]); + } + } else { + expect(builderChunks).toHaveLength(4); + } + + const styles = (entry: string, defaults: boolean) => { + const chunks = compilation.entrypoints.get(entry)!.chunks; + + return chunks + .filter(chunk => + [...compilation.chunkGraph.getChunkModulesIterable(chunk)].some( + module => + module.type === "css/mini-extract" && (module.layer === DefaultStylesLayer) === defaults + ) + ) + .flatMap(chunk => [...chunk.files].filter(file => file.endsWith(".css"))) + .map(file => compilation.getAsset(file)!.source.source().toString()) + .join("\n"); + }; + + for (const entry of ["normal", "shadow", "iframe", "relay", "main", "popup"]) { + expect(styles(entry, true)).toContain("--child-style"); + expect(styles(entry, true)).toContain("--vendor-style"); + if (["shadow", "iframe", "relay"].includes(entry)) { + expect(styles(entry, false)).not.toContain("--vendor-style"); + } else { + expect(styles(entry, false)).toContain("--vendor-style"); + } + } + + const background = compilation.entrypoints.get("background")!.getFiles(); + + for (const [name, entry] of compilation.entrypoints) { + if (name !== "background") { + expect(entry.getFiles().some(file => background.includes(file))).toBe(false); + } + } + } finally { + await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + fs.rmSync(output, {recursive: true, force: true}); + } + } +); diff --git a/tests/integration/build/style-routing/delivery-spike.integration.test.ts b/tests/integration/build/style-routing/delivery-spike.integration.test.ts new file mode 100644 index 00000000..f1c3f0ac --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike.integration.test.ts @@ -0,0 +1,305 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import {setImmediate} from "timers"; +import type {Filename, NormalModule, Stats} from "@rspack/core"; +import {getCompilationBuildAssets} from "@cli/bundler/plugins/utils"; +import { + closeCompiler, + contentEntries, + createCompiler, + fixtures, + mainLayer, + project, + runCompiler, + worldLayer, + watchSelections, +} from "./delivery-spike/compiler"; +import {executeEntry} from "./delivery-spike/runtime"; + +const flush = () => new Promise(resolve => setImmediate(resolve)); +const assetText = (stats: Stats, file: string) => stats.compilation.getAsset(file)!.source.source().toString(); +const readManifest = (stats: Stats): chrome.runtime.ManifestV3 => JSON.parse(assetText(stats, "manifest.json")); +const cssText = (stats: Stats, files: readonly string[]) => files.map(file => assetText(stats, file)).join("\n"); + +const moduleIds = (stats: Stats, resource: string) => + [...stats.compilation.modules] + .filter(module => (module as NormalModule).resource === resource) + .map(module => ({layer: module.layer ?? null, id: stats.compilation.chunkGraph.getModuleId(module)})); + +const verifyGraph = (stats: Stats, commonChunks: boolean) => { + for (const resource of [ + path.join(fixtures, "Panel.js"), + path.join(project, "node_modules/react/index.js"), + path.join(project, "node_modules/@addon-core/browser/dist/index.js"), + path.join(project, "dist/entry/content/lifecycle/Builder.js"), + ]) { + const modules = moduleIds(stats, resource); + expect(modules.map(module => module.layer).sort()).toEqual([worldLayer, mainLayer, null].sort()); + expect(new Set(modules.map(module => module.id)).size).toBe(3); + } + + const panel = [...stats.compilation.modules].find( + module => (module as NormalModule).resource === path.join(fixtures, "Panel.js") && module.layer === worldLayer + )!; + const chunks = stats.compilation.chunkGraph.getModuleChunks(panel); + expect(chunks).toHaveLength(commonChunks ? 1 : contentEntries.length); + + const background = stats.compilation.entrypoints.get("background")!.getFiles(); + + for (const entry of contentEntries) { + expect( + stats.compilation.entrypoints + .get(entry)! + .getFiles() + .some(file => background.includes(file)) + ).toBe(false); + } +}; + +const verifyDelivery = (stats: Stats, entry: string, isolated: boolean) => { + const map = getCompilationBuildAssets(stats.compilation)!; + const manifest = readManifest(stats); + const content = manifest.content_scripts!.find(script => script.js?.includes(map[entry].initial.js.at(-1)!))!; + expect(content).toBeDefined(); + const documentCss = cssText(stats, content.css ?? []); + expect(documentCss).toContain("--delivery-document: initial"); + expect(documentCss).toContain("--delivery-document-child: inherited"); + expect(documentCss.includes("--delivery-ui: shared")).toBe(!isolated); + expect(documentCss.includes("--vendor-style: inherited")).toBe(!isolated); + expect(documentCss).not.toContain("--delivery-ui: lazy"); + expect(documentCss).not.toContain("--delivery-document: lazy"); + + const harness = executeEntry(stats, entry); + + try { + expect(harness.probe.assets()).toEqual({initial: map[entry].initial, async: map[entry].async}); + expect(harness.probe.styles() !== undefined).toBe(isolated); + expect(harness.scripts).toEqual([]); + + if (isolated) { + harness.addRoot(entry === "iframe" ? "iframe" : "shadow"); + const files = harness.requests.map(request => request.file); + const styles = cssText(stats, files); + expect(styles).toContain("--delivery-ui: shared"); + expect(styles).toContain("--vendor-style: inherited"); + expect(styles).not.toContain("--delivery-document"); + expect(manifest.web_accessible_resources!.flatMap(rule => rule.resources)).toEqual( + expect.arrayContaining(files) + ); + + // The ordinary entry retains both categories together in its own stylesheet. + const normal = manifest.content_scripts!.find(script => + script.js?.includes(map.normal.initial.js.at(-1)!) + )!; + expect(cssText(stats, normal.css ?? [])).toContain("--delivery-ui: shared"); + expect(cssText(stats, normal.css ?? [])).toContain("--delivery-document: initial"); + } + } finally { + harness.close(); + } +}; + +const verifyLazy = async (stats: Stats, entry: string, isolated: boolean) => { + const harness = executeEntry(stats, entry); + + try { + if (isolated) { + harness.addRoot("shadow", "shadow-a"); + harness.addRoot("iframe", "iframe-a"); + + for (const request of harness.requests) { + harness.settle(request); + } + } + + const initialCount = harness.requests.length; + const loaded = harness.probe.load(); + let complete = false; + void loaded.then(() => { + complete = true; + }); + const requests = harness.requests.slice(initialCount); + expect(harness.scripts.length).toBeGreaterThan(0); + expect(requests.map(request => request.target).sort()).toEqual( + isolated ? ["document", "iframe-a", "shadow-a"] : Array(entry === "popup" ? 1 : 2).fill("document") + ); + + if (isolated) { + expect( + cssText( + stats, + requests.filter(request => request.target === "document").map(request => request.file) + ) + ).toContain("--delivery-document: lazy"); + expect( + cssText( + stats, + requests.filter(request => request.target !== "document").map(request => request.file) + ) + ).not.toContain("--delivery-document"); + } + + const last = isolated + ? requests.find(request => request.target === (entry === "iframe" ? "document" : "iframe-a"))! + : requests.at(-1)!; + + for (const request of requests.filter(request => request !== last)) { + harness.settle(request); + } + + await flush(); + expect(complete).toBe(false); + harness.settle(last); + await expect(loaded).resolves.toEqual(expect.objectContaining({loaded: true})); + + if (isolated) { + const count = harness.requests.length; + harness.addRoot("shadow", "late"); + const late = harness.requests.slice(count); + expect( + cssText( + stats, + late.map(request => request.file) + ) + ).toContain("--delivery-ui: lazy"); + expect( + cssText( + stats, + late.map(request => request.file) + ) + ).not.toContain("--delivery-document"); + } + } finally { + harness.close(); + } +}; + +test.each<{name: string; filename: Filename; cssFilename: Filename; commonChunks: boolean}>([ + ...["contenthash", "chunkhash", "fullhash"].map(hash => ({ + name: hash, + filename: `js/[name].[${hash}:8].js`, + cssFilename: `css/[name].[${hash}:8].css`, + commonChunks: true, + })), + { + name: "callback", + filename: () => "custom/[name].[contenthash:8].js", + cssFilename: () => "custom/[name].[contenthash:8].css", + commonChunks: true, + }, + { + name: "without common chunks", + filename: "js/[name].[contenthash:8].js", + cssFilename: "css/[name].[contenthash:8].css", + commonChunks: false, + }, +])("routes CSS per entry without splitting its JavaScript layer ($name)", async options => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-delivery-spike-")); + const compiler = await createCompiler(output, options); + + try { + const stats = await runCompiler(compiler); + verifyGraph(stats, options.commonChunks); + + // Separate entry runtimes execute in the same page/global chunk queue. + const sharedContext = executeEntry(stats, "shadow"); + + try { + const iframe = sharedContext.loadEntry("iframe"); + const normal = sharedContext.loadEntry("normal"); + expect(iframe.styles()).not.toBe(sharedContext.probe.styles()); + expect(normal.styles()).toBeUndefined(); + const map = getCompilationBuildAssets(stats.compilation)!; + + for (const [entry, probe] of [ + ["shadow", sharedContext.probe], + ["iframe", iframe], + ["normal", normal], + ] as const) { + expect(probe.assets()).toEqual({initial: map[entry].initial, async: map[entry].async}); + } + } finally { + sharedContext.close(); + } + + for (const entry of contentEntries) { + verifyDelivery(stats, entry, ["shadow", "iframe", "relay"].includes(entry)); + } + + for (const entry of ["normal", "shadow", "iframe", "popup"]) { + await verifyLazy(stats, entry, entry === "shadow" || entry === "iframe"); + } + } finally { + await closeCompiler(compiler); + fs.rmSync(output, {recursive: true, force: true}); + } +}); + +test("watch switches document/shadow/iframe delivery without changing JavaScript module identities", async () => { + const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-delivery-watch-")); + const selectionFile = path.join(output, "selection.json"); + const states = path.join(__dirname, "delivery-spike/states"); + fs.copyFileSync(path.join(states, "none.json"), selectionFile); + const compiler = await createCompiler(path.join(output, "dist"), {selectionFile}); + let completion: ((error: Error | null, stats?: Stats) => void) | undefined; + + const next = (state: string, update?: () => void) => + new Promise((resolve, reject) => { + let observed: string | undefined; + const timer = setTimeout(() => { + completion = undefined; + reject( + new Error(`Delivery watch expected ${state}, last compilation selected ${observed ?? "unknown"}`) + ); + }, 10_000); + + completion = (error, stats) => { + if (!error && stats && !stats.hasErrors()) { + observed = watchSelections.get(stats.compilation); + + if (observed !== state) { + return; + } + } + + clearTimeout(timer); + completion = undefined; + + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + resolve(stats); + } + }; + + update?.(); + }); + + const first = next("none"); + const watcher = compiler.watch({poll: 50}, (error, stats) => completion?.(error, stats)); + + try { + let stats = await first; + const identities = moduleIds(stats, path.join(fixtures, "Panel.js")); + verifyDelivery(stats, "switch", false); + + for (const state of ["shadow", "iframe", "none", "shadow"]) { + stats = await next(state, () => { + fs.writeFileSync(selectionFile, fs.readFileSync(path.join(states, state + ".json"))); + }); + + verifyGraph(stats, true); + expect(moduleIds(stats, path.join(fixtures, "Panel.js"))).toEqual(identities); + expect(getCompilationBuildAssets(stats.compilation)!.switch.initial.css).toHaveLength( + state === "none" ? 1 : 2 + ); + verifyDelivery(stats, "switch", state !== "none"); + await verifyLazy(stats, "switch", state !== "none"); + } + } finally { + await new Promise(resolve => watcher.close(resolve)); + await closeCompiler(compiler); + fs.rmSync(output, {recursive: true, force: true}); + } +}, 30_000); diff --git a/tests/integration/build/style-routing/delivery-spike/compiler.ts b/tests/integration/build/style-routing/delivery-spike/compiler.ts new file mode 100644 index 00000000..666d95d0 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/compiler.ts @@ -0,0 +1,153 @@ +import fs from "fs"; +import path from "path"; +import {rspack, type Configuration, type Compilation, type Compiler, type Filename, type Stats} from "@rspack/core"; +import {getContentLayer} from "@cli/bundler/layers"; +import stylePlugin from "@cli/plugins/style"; +import type {ReadonlyConfig} from "@typing/config"; +import IsolatedStylesPlugin from "@cli/bundler/plugins/isolated-styles"; +import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import ManifestPlugin from "@cli/bundler/plugins/manifest"; +import {GenerateModulePlugin} from "@cli/bundler/plugins/generate-module"; +import {createRuntimeModule} from "@cli/bundler/plugins/utils"; +import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "@cli/plugins/output/runtime"; +import ManifestV3 from "@cli/builders/manifest/ManifestV3"; +import {Browser} from "@typing/browser"; +import {ContentScriptStylesRuntimeProperty, ContentScriptWorld} from "@typing/content"; + +export const project = path.resolve(__dirname, "../../../../.."); +export const fixtures = path.join(__dirname, "src"); +export const origin = "https://extension.test/"; +export const contentEntries = ["normal", "shadow", "iframe", "relay", "switch"]; +export const worldLayer = getContentLayer(ContentScriptWorld.Isolated); +export const mainLayer = getContentLayer(ContentScriptWorld.Main); +export const watchSelections = new WeakMap(); + +interface CompilerOptions { + filename?: Filename; + cssFilename?: Filename; + commonChunks?: boolean; + selectionFile?: string; +} + +// Exercise production CSS classification and delivery with one JavaScript layer per world. +export const createCompiler = async (output: string, options: CompilerOptions = {}): Promise => { + const filename = options.filename ?? "js/[name].[contenthash:8].js"; + const cssFilename = options.cssFilename ?? "css/[name].[contenthash:8].css"; + let selection = "none"; + const config = { + rootDir: project, + app: "test", + mergeStyles: false, + cssDir: "", + cssFilename, + cssIdentName: "[local]", + } as ReadonlyConfig; + const handler = stylePlugin().bundler!; + const styles = (typeof handler === "function" ? await handler({config, rspack: {}}) : handler) as Configuration; + + return rspack({ + context: fixtures, + mode: "production", + devtool: false, + entry: { + ...Object.fromEntries(contentEntries.map(name => [name, {import: "./entry.js", layer: worldLayer}])), + main: {import: "./entry.js", layer: mainLayer, asyncChunks: false}, + popup: "./entry.js", + background: "./background.js", + }, + output: { + path: output, + filename, + chunkFilename: filename, + publicPath: origin, + uniqueName: "deliverySpike", + chunkLoadTimeout: 2_000, + }, + resolve: { + alias: {"adnbn/entry/content/vanilla$": path.join(project, "dist/entry/content/adapters/vanilla/index.js")}, + modules: [path.resolve(__dirname, "../src/vendor"), path.join(project, "node_modules")], + extensions: [".js"], + }, + resolveLoader: {modules: [path.join(project, "node_modules")]}, + module: styles.module, + optimization: { + minimize: false, + concatenateModules: false, + moduleIds: "deterministic", + chunkIds: "deterministic", + splitChunks: { + cacheGroups: { + default: false, + defaultVendors: false, + ...(options.commonChunks === false + ? {} + : { + sharedContent: { + layer: worldLayer, + chunks: "all", + minChunks: 2, + minSize: 0, + name: "common.content", + }, + }), + }, + }, + }, + plugins: [ + ...styles.plugins!, + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), + }), + new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, + fullMapEntrypoint: "background", + cssFilename, + cssChunkFilename: cssFilename, + }), + new IsolatedStylesPlugin({ + cssFilename, + cssChunkFilename: cssFilename, + property: ContentScriptStylesRuntimeProperty, + test: entry => + ["shadow", "iframe", "relay"].includes(entry) || (entry === "switch" && selection !== "none"), + }), + new ManifestPlugin( + new ManifestV3(Browser.Chrome).setContentScripts( + new Set([...contentEntries, "main"].map(entry => ({entry, matches: ["https://example.com/*"]}))) + ) + ), + { + apply(compiler) { + if (!options.selectionFile) { + return; + } + + const selectionFile = options.selectionFile; + compiler.hooks.watchRun.tap("DeliverySelection", () => { + selection = JSON.parse(fs.readFileSync(selectionFile, "utf8")).isolation; + }); + compiler.hooks.compilation.tap("DeliverySelection", compilation => { + watchSelections.set(compilation, selection); + compilation.fileDependencies.add(selectionFile); + }); + }, + }, + ], + }); +}; + +export const runCompiler = (compiler: Compiler): Promise => + new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + resolve(stats); + } + }); + }); + +export const closeCompiler = (compiler: Compiler): Promise => + new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); diff --git a/tests/integration/build/style-routing/delivery-spike/runtime.ts b/tests/integration/build/style-routing/delivery-spike/runtime.ts new file mode 100644 index 00000000..7134f205 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/runtime.ts @@ -0,0 +1,171 @@ +import vm from "vm"; +import type {Stats} from "@rspack/core"; +import type {EntrypointAssets} from "@typing/entrypoint"; +import {origin} from "./compiler"; + +interface Element { + tagName: string; + href: string; + src: string; + ownerDocument: Document; + parentNode?: Root; + onload?: (event?: {type: string; target: Element}) => void; + onerror?: (event: {type: string; target: Element}) => void; + getAttribute(name: string): string | null; + setAttribute(name: string, value: string): void; + remove(): void; +} + +interface Document { + createElement(tag: string): Element; +} + +interface Root { + ownerDocument: Document; + insertBefore(node: Element, target: Element | null): Element; + removeChild(node: Element): void; +} + +interface StylesRuntime { + initialize(resolveUrl: (file: string) => string): void; + add(root: Root, target: Element | null): void; + delete(root: Root): void; +} + +interface Probe { + styles(): StylesRuntime | undefined; + assets(): EntrypointAssets; + load(): Promise<{loaded: boolean}>; +} + +export interface StyleRequest { + target: string; + file: string; + link: Element; + done: boolean; +} + +// DOM/network boundary only. Emitted JavaScript and CSS scheduling are real; +// completing a link represents the browser reporting stylesheet readiness. +export const executeEntry = (stats: Stats, entry: string) => { + const requests: StyleRequest[] = []; + const scripts: string[] = []; + const roots: Root[] = []; + const timers = new Set>(); + const source = (file: string) => stats.compilation.getAsset(file)!.source.source().toString(); + let context: vm.Context; + + const createDocument = (): Document => ({ + createElement(tag: string): Element { + const attributes = new Map(); + + return { + tagName: tag.toUpperCase(), + href: "", + src: "", + ownerDocument: this, + getAttribute: name => attributes.get(name) ?? null, + setAttribute: (name, value) => { + attributes.set(name, value); + }, + remove() { + this.parentNode?.removeChild(this); + }, + }; + }, + }); + + const createRoot = (ownerDocument: Document, name: string) => { + const root: Root = { + ownerDocument, + insertBefore(node) { + node.parentNode = root; + + if (node.tagName === "SCRIPT") { + const file = node.src.slice(origin.length); + scripts.push(file); + vm.runInContext(source(file), context); + queueMicrotask(() => node.onload?.({type: "load", target: node})); + } else { + requests.push({target: name, file: node.href.slice(origin.length), link: node, done: false}); + } + + return node; + }, + removeChild(node) { + node.parentNode = undefined; + }, + }; + + return root; + }; + + const page = createDocument(); + const head = createRoot(page, "document"); + context = vm.createContext({ + document: { + ...page, + head: {...head, appendChild: (node: Element) => head.insertBefore(node, null)}, + getElementsByTagName: () => [], + }, + console, + setTimeout: (callback: () => void, delay: number) => { + const timer = setTimeout(callback, delay); + timers.add(timer); + + return timer; + }, + clearTimeout: (timer: ReturnType) => { + timers.delete(timer); + clearTimeout(timer); + }, + }); + vm.runInContext("self = globalThis", context); + + const loadEntry = (name: string): Probe => { + for (const file of stats.compilation.entrypoints + .get(name)! + .getFiles() + .filter(file => file.endsWith(".js"))) { + vm.runInContext(source(file), context); + } + + return context.deliveryProbe as Probe; + }; + + const probe = loadEntry(entry); + const runtime = probe.styles(); + runtime?.initialize(file => origin + file); + + return { + probe, + loadEntry, + requests, + scripts, + addRoot(kind: "shadow" | "iframe", name: string = kind) { + const root = createRoot(kind === "iframe" ? createDocument() : page, name); + roots.push(root); + runtime!.add(root, null); + + return root; + }, + settle(request: StyleRequest, event = "load") { + request.done = true; + + if (event === "load") { + request.link.onload?.({type: event, target: request.link}); + } else { + request.link.onerror?.({type: event, target: request.link}); + } + }, + close() { + for (const root of roots) { + runtime!.delete(root); + } + + for (const timer of timers) { + clearTimeout(timer); + } + }, + }; +}; diff --git a/tests/integration/build/style-routing/delivery-spike/src/Panel.js b/tests/integration/build/style-routing/delivery-spike/src/Panel.js new file mode 100644 index 00000000..cc236067 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/Panel.js @@ -0,0 +1,11 @@ +import {createElement} from "react"; +import {getUrl} from "@addon-core/browser"; +import {Builder} from "adnbn/entry/content/vanilla"; +import styles from "./panel.module.scss"; +import "fixture-styles"; + +export const libraries = {getUrl, Builder}; + +export function Panel() { + return createElement("section", {className: styles.panel}, "Shared panel"); +} diff --git a/tests/integration/build/style-routing/delivery-spike/src/_document-child.scss b/tests/integration/build/style-routing/delivery-spike/src/_document-child.scss new file mode 100644 index 00000000..8da03d26 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/_document-child.scss @@ -0,0 +1,3 @@ +.document-child { + --delivery-document-child: inherited; +} diff --git a/tests/integration/build/style-routing/delivery-spike/src/_tokens.scss b/tests/integration/build/style-routing/delivery-spike/src/_tokens.scss new file mode 100644 index 00000000..4770aacc --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/_tokens.scss @@ -0,0 +1 @@ +$value: shared; diff --git a/tests/integration/build/style-routing/delivery-spike/src/background.js b/tests/integration/build/style-routing/delivery-spike/src/background.js new file mode 100644 index 00000000..deaa51f3 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/background.js @@ -0,0 +1,3 @@ +import {readAssetsMap} from "#adnbn/runtime"; + +globalThis.readDeliveryMap = readAssetsMap; diff --git a/tests/integration/build/style-routing/delivery-spike/src/document.scss b/tests/integration/build/style-routing/delivery-spike/src/document.scss new file mode 100644 index 00000000..02c79f38 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/document.scss @@ -0,0 +1,9 @@ +@use "./document-child"; + +:global(:root) { + --delivery-cascade: document; +} + +.document { + --delivery-document: initial; +} diff --git a/tests/integration/build/style-routing/delivery-spike/src/entry.js b/tests/integration/build/style-routing/delivery-spike/src/entry.js new file mode 100644 index 00000000..f8ac247e --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/entry.js @@ -0,0 +1,11 @@ +import {readAssets, readContentStyles} from "#adnbn/runtime"; +import {Panel, libraries} from "./Panel.js"; +import "./document.scss?unisolated"; + +globalThis.deliveryProbe = { + Panel, + libraries, + assets: readAssets, + styles: readContentStyles, + load: () => import("./lazy.js"), +}; diff --git a/tests/integration/build/style-routing/delivery-spike/src/lazy-document.scss b/tests/integration/build/style-routing/delivery-spike/src/lazy-document.scss new file mode 100644 index 00000000..e20d96c3 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/lazy-document.scss @@ -0,0 +1,3 @@ +.lazy-document { + --delivery-document: lazy; +} diff --git a/tests/integration/build/style-routing/delivery-spike/src/lazy.js b/tests/integration/build/style-routing/delivery-spike/src/lazy.js new file mode 100644 index 00000000..32625bbe --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/lazy.js @@ -0,0 +1,4 @@ +import "./lazy.scss"; +import "./lazy-document.scss?unisolated"; + +export const loaded = true; diff --git a/tests/integration/build/style-routing/delivery-spike/src/lazy.scss b/tests/integration/build/style-routing/delivery-spike/src/lazy.scss new file mode 100644 index 00000000..dd516df8 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/lazy.scss @@ -0,0 +1,3 @@ +.lazy { + --delivery-ui: lazy; +} diff --git a/tests/integration/build/style-routing/delivery-spike/src/package.json b/tests/integration/build/style-routing/delivery-spike/src/package.json new file mode 100644 index 00000000..43288d05 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "sideEffects": true, + "type": "module" +} diff --git a/tests/integration/build/style-routing/delivery-spike/src/panel.module.scss b/tests/integration/build/style-routing/delivery-spike/src/panel.module.scss new file mode 100644 index 00000000..89f59b0c --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/src/panel.module.scss @@ -0,0 +1,9 @@ +@use "./tokens"; + +:global(:root) { + --delivery-cascade: default; +} + +.panel { + --delivery-ui: #{tokens.$value}; +} diff --git a/tests/integration/build/style-routing/delivery-spike/states/iframe.json b/tests/integration/build/style-routing/delivery-spike/states/iframe.json new file mode 100644 index 00000000..143b1f2e --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/states/iframe.json @@ -0,0 +1 @@ +{ "isolation": "iframe" } diff --git a/tests/integration/build/style-routing/delivery-spike/states/none.json b/tests/integration/build/style-routing/delivery-spike/states/none.json new file mode 100644 index 00000000..a3fe6720 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/states/none.json @@ -0,0 +1 @@ +{ "isolation": "none" } diff --git a/tests/integration/build/style-routing/delivery-spike/states/shadow.json b/tests/integration/build/style-routing/delivery-spike/states/shadow.json new file mode 100644 index 00000000..10bee9a0 --- /dev/null +++ b/tests/integration/build/style-routing/delivery-spike/states/shadow.json @@ -0,0 +1 @@ +{ "isolation": "shadow" } diff --git a/tests/integration/build/style-routing/routing-spike.integration.test.ts b/tests/integration/build/style-routing/routing-spike.integration.test.ts index a2050e5f..bf4fc5cd 100644 --- a/tests/integration/build/style-routing/routing-spike.integration.test.ts +++ b/tests/integration/build/style-routing/routing-spike.integration.test.ts @@ -21,26 +21,38 @@ const createCompiler = (output: string, filename: Filename, enabled: () => boole popup: "./entry.js", }, output: {path: output, filename, chunkFilename: filename, publicPath: "/", uniqueName: "routingSpike"}, + resolve: {modules: [path.join(fixtures, "vendor"), "node_modules"]}, resolveLoader: {modules: [path.join(project, "node_modules")]}, module: { rules: [ { - test: /\.css$/, + test: /\.(css|scss)$/, type: "javascript/auto", oneOf: [ { - issuerLayer: /^adnbn:content:/, - resourceQuery: /[?&]isolation(?:[=&]|$)/, + issuerLayer: {or: [/^adnbn:content:/, layer]}, + resourceQuery: {not: [/[?&]unisolated(?:[=&]|$)/]}, layer, use: [ CssExtractRspackPlugin.loader, - {loader: "css-loader", ident: "isolation", options: {modules: false}}, + { + loader: "css-loader", + ident: "isolation", + options: {modules: {auto: true, namedExport: false}}, + }, + "sass-loader", ], }, { + layer: "fixture:document", use: [ CssExtractRspackPlugin.loader, - {loader: "css-loader", ident: "document", options: {modules: false}}, + { + loader: "css-loader", + ident: "document", + options: {modules: {auto: true, namedExport: false}}, + }, + "sass-loader", ], }, ], @@ -72,12 +84,20 @@ const createCompiler = (output: string, filename: Filename, enabled: () => boole !enabled() || chunk.name === "popup" || module.identifier() !== "webpack/runtime/css loading" - ) + ) { return; + } + const source = module.source; - if (!source || typeof source.source !== "string") + + if (!source || typeof source.source !== "string") { throw new Error("CSS runtime source unavailable"); - if (!source.source) return; + } + + if (!source.source) { + return; + } + const ids = Object.fromEntries( [...compilation.chunks] .filter(chunk => @@ -87,8 +107,13 @@ const createCompiler = (output: string, filename: Filename, enabled: () => boole ) .map(chunk => [chunk.id, true]) ); + const position = source.source.indexOf(boundary); - if (position < 0) throw new Error("CSS runtime seam unavailable"); + + if (position < 0) { + throw new Error("CSS runtime seam unavailable"); + } + source.source = source.source.slice(0, position) + `var loadPageStylesheet = loadStylesheet; @@ -108,13 +133,17 @@ const createCompiler = (output: string, filename: Filename, enabled: () => boole const run = (compiler: Compiler): Promise => new Promise((resolve, reject) => compiler.run((error, stats) => { - if (error || !stats || stats.hasErrors()) + if (error || !stats || stats.hasErrors()) { reject(error ?? new Error(stats?.toString({all: false, errors: true}))); - else resolve(stats); + } else { + resolve(stats); + } }) ); + const close = (compiler: Compiler): Promise => new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); + const css = (stats: Stats, files: Iterable): string => [...files] .filter(file => file.endsWith(".css")) @@ -129,6 +158,7 @@ test.each([ ])("partitions CSS before rendering chunks and leaves popup extraction intact (%s)", async filename => { const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-routing-")); const compiler = createCompiler(output, filename); + try { const stats = await run(compiler); const popup = stats.compilation.entrypoints.get("popup")!; @@ -136,35 +166,46 @@ test.each([ const relay = stats.compilation.entrypoints.get("relay")!; expect(popup.getFiles().filter(file => file.endsWith(".css"))).toHaveLength(1); expect(css(stats, popup.getFiles())).toContain(".nested"); + const uiChunks = [...stats.compilation.chunks].filter(chunk => [...stats.compilation.chunkGraph.getChunkModulesIterable(chunk)].some( m => m.type === "css/mini-extract" && m.layer === layer ) ); + expect(uiChunks).toHaveLength(2); + for (const chunk of uiChunks) { expect(css(stats, chunk.files)).not.toContain(".page"); expect(css(stats, chunk.files)).not.toContain(".lazy-page"); expect([...chunk.files].some(file => file.endsWith(".js"))).toBe(false); } + expect(content.getFiles()).toHaveLength(3); + expect( content.getFiles().filter(file => file.endsWith(".css") && relay.getFiles().includes(file)) ).toHaveLength(1); + expect(content.getFiles().some(file => file.includes("background"))).toBe(false); const requested: {target: string; resolve(): void}[] = []; let context: vm.Context; + const head = { appendChild(node: any) { node.parentNode = head; + if (node.tagName === "SCRIPT") { vm.runInContext(fs.readFileSync(path.join(output, node.src.slice(1)), "utf8"), context); queueMicrotask(() => node.onload?.({type: "load", target: node})); - } else requested.push({target: "page", resolve: () => node.onload({type: "load", target: node})}); + } else { + requested.push({target: "page", resolve: () => node.onload({type: "load", target: node})}); + } }, removeChild() {}, }; + const sandbox = { setTimeout, clearTimeout, @@ -180,15 +221,20 @@ test.each([ }, loadIsolatedCss: () => new Promise(resolve => requested.push({target: "isolation", resolve})), }; + context = vm.createContext(sandbox); vm.runInContext("self = globalThis", context); + for (const file of content.getFiles().filter(file => file.endsWith(".js"))) vm.runInContext(fs.readFileSync(path.join(output, file), "utf8"), context); + const loaded = vm.runInContext("loadPanel()", context) as Promise<{loaded: boolean}>; let resolved = false; + void loaded.then(() => { resolved = true; }); + expect(requested.map(item => item.target).sort()).toEqual(["isolation", "page"]); requested[0].resolve(); await new Promise(resolve => setImmediate(resolve)); @@ -206,16 +252,22 @@ test("recomputes selective CSS routing on watch rebuilds", async () => { let enabled = true; const compiler = createCompiler(output, "[name].[contenthash:8].js", () => enabled); let next: {resolve(stats: Stats): void; reject(error: Error): void}; + const result = () => new Promise((resolve, reject) => { next = {resolve, reject}; }); + let pending = result(); + const watching = compiler.watch({}, (error, stats) => { - if (error || !stats || stats.hasErrors()) + if (error || !stats || stats.hasErrors()) { next.reject(error ?? new Error(stats?.toString({all: false, errors: true}))); - else next.resolve(stats); + } else { + next.resolve(stats); + } }); + try { for (const selection of [true, false, true]) { if (selection !== enabled) { @@ -223,13 +275,16 @@ test("recomputes selective CSS routing on watch rebuilds", async () => { pending = result(); watching.invalidate(); } + const stats = await pending; const entry = stats.compilation.entrypoints.get("content")!; + const source = entry .getFiles() .filter(file => file.endsWith(".js")) .map(file => stats.compilation.getAsset(file)!.source.source()) .join("\n"); + expect(source.includes("loadIsolatedCss")).toBe(selection); } } finally { diff --git a/tests/integration/build/style-routing/selective-split.integration.test.ts b/tests/integration/build/style-routing/selective-split.integration.test.ts new file mode 100644 index 00000000..4d5e01aa --- /dev/null +++ b/tests/integration/build/style-routing/selective-split.integration.test.ts @@ -0,0 +1,268 @@ +import {mkdtemp, readFile, rm, writeFile} from "fs/promises"; +import os from "os"; +import path from "path"; +import type {NormalModule, Stats} from "@rspack/core"; + +import {getContentLayer} from "@cli/bundler/layers"; +import {ContentScriptWorld} from "@typing/content"; + +import {closeCompiler, createCompiler, fixture, orders, runCompiler, watchSelections} from "./selective-split/compiler"; + +test("selective split keeps one shared lazy module and orders document CSS before UI CSS for both import orders", async () => { + const output = await mkdtemp(path.join(os.tmpdir(), "adnbn-selective-split-")); + const compiler = await createCompiler(output); + + try { + const {compilation} = await runCompiler(compiler); + expect(compilation.warnings).toHaveLength(0); + + for (const order of orders) { + const modules = [...compilation.modules].filter( + module => (module as NormalModule).resource === path.join(fixture, `${order}.js`) + ); + + expect(modules).toHaveLength(1); + const chunks = compilation.chunkGraph.getModuleChunks(modules[0]); + expect(chunks).toHaveLength(1); + const groups = [...chunks[0].groupsIterable]; + expect(groups).toHaveLength(1); + expect( + groups[0] + .getParents() + .map(parent => parent.name) + .sort() + ).toEqual(["normal", "shadow"]); + + const files = groups[0].chunks.flatMap(chunk => [...chunk.files].filter(file => file.endsWith(".css"))); + expect(files).toHaveLength(2); + const sources = files.map(file => compilation.getAsset(file)!.source.source().toString()); + expect(sources[0]).toContain("--split-document:"); + expect(sources[0]).not.toContain("--split-ui:"); + expect(sources[1]).toContain("--split-ui:"); + expect(sources[1]).not.toContain("--split-document:"); + } + } finally { + await closeCompiler(compiler); + await rm(output, {recursive: true, force: true}); + } +}); + +test("does not split the shared lazy CSS when neither consumer has isolated delivery", async () => { + const output = await mkdtemp(path.join(os.tmpdir(), "adnbn-unselected-split-")); + const compiler = await createCompiler(output, false); + + try { + const {compilation} = await runCompiler(compiler); + + for (const order of orders) { + const module = [...compilation.modules].find( + module => (module as NormalModule).resource === path.join(fixture, `${order}.js`) + )!; + + const [chunk] = compilation.chunkGraph.getModuleChunks(module); + const [group] = chunk.groupsIterable; + const files = group.chunks.flatMap(chunk => [...chunk.files].filter(file => file.endsWith(".css"))); + expect(files).toHaveLength(1); + const source = compilation.getAsset(files[0])!.source.source().toString(); + const categories = [...source.matchAll(/--split-(document|ui):/g)].map(match => match[1]); + expect(categories).toEqual(order === "document-first" ? ["document", "ui"] : ["ui", "document"]); + } + } finally { + await closeCompiler(compiler); + await rm(output, {recursive: true, force: true}); + } +}); + +test("ordinary initial CSS stays named and mixed in import order beside isolated entries", async () => { + const output = await mkdtemp(path.join(os.tmpdir(), "adnbn-initial-split-")); + const compiler = await createCompiler(output, true, {initial: true}); + + try { + const {compilation} = await runCompiler(compiler); + expect(compilation.errors).toHaveLength(0); + const manifest = JSON.parse(compilation.getAsset("manifest.json")!.source.source().toString()); + const war = manifest.web_accessible_resources.flatMap((rule: {resources: string[]}) => rule.resources); + + for (const order of orders) { + for (const type of ["popup", "options", "page", "content", "relay"]) { + const name = `${type}-${order}`; + const entry = compilation.entrypoints.get(name)!; + const files = entry.getFiles().filter(file => file.endsWith(".css")); + expect(files).toEqual([expect.stringMatching(new RegExp(`^css/${name}\\.[a-f0-9]+\\.css$`))]); + const source = compilation.getAsset(files[0])!.source.source().toString(); + const categories = [...source.matchAll(/--split-(document|ui):/g)].map(match => match[1]); + expect(categories).toEqual(order === "document-first" ? ["document", "ui"] : ["ui", "document"]); + + if (type === "content" || type === "relay") { + const script = manifest.content_scripts.find((script: {js: string[]}) => + script.js.some(file => entry.getFiles().includes(file)) + ); + expect(script.css).toEqual(files); + expect(war).not.toEqual(expect.arrayContaining(files)); + } + } + + const name = `shadow-${order}`; + const entry = compilation.entrypoints.get(name)!; + const files = entry.getFiles().filter(file => file.endsWith(".css")); + expect(files).toHaveLength(2); + const ui = files.find(file => + compilation.getAsset(file)!.source.source().toString().includes("--split-ui:") + )!; + const document = files.find(file => file !== ui)!; + expect(ui).toMatch(new RegExp(`^css/${name}\\.[a-f0-9]+\\.css$`)); + const script = manifest.content_scripts.find((script: {js: string[]}) => + script.js.some(file => entry.getFiles().includes(file)) + ); + expect(script.css).toEqual([document]); + expect(war).toContain(ui); + } + + for (const chunk of compilation.entrypoints.get("shadow")!.getEntrypointChunk().getAllAsyncChunks()) { + for (const file of chunk.files) { + if (file.endsWith(".css")) { + expect(war).toContain(file); + } + } + } + } finally { + await closeCompiler(compiler); + await rm(output, {recursive: true, force: true}); + } +}); + +test("watch adds and removes the shared lazy split and restores the ordinary CSS runtime", async () => { + const output = await mkdtemp(path.join(os.tmpdir(), "adnbn-selective-watch-")); + const selectionFile = path.join(output, "selection.json"); + const setState = async (state: string) => + writeFile(selectionFile, await readFile(path.join(__dirname, "delivery-spike/states", `${state}.json`))); + await setState("none"); + const compiler = await createCompiler(output, false, {initial: true, selectionFile}); + let queuedState: string | undefined; + + compiler.hooks.done.tapPromise("ChangeSelectionBeforeWatchResult", async () => { + if (queuedState === undefined) { + return; + } + + const state = queuedState; + queuedState = undefined; + await setState(state); + }); + + let complete: ((error: Error | null, stats?: Stats) => void) | undefined; + const next = (isolated: boolean, update?: () => Promise) => + new Promise((resolve, reject) => { + let observed: boolean | undefined; + const timer = setTimeout(() => { + complete = undefined; + reject( + new Error( + `Selective CSS watch expected isolation=${isolated}, last compilation selected ${observed ?? "unknown"}` + ) + ); + }, 10_000); + + complete = (error, stats) => { + if (!error && stats && !stats.hasErrors()) { + observed = watchSelections.get(stats.compilation); + + if (observed !== isolated) { + return; + } + } + + clearTimeout(timer); + complete = undefined; + + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + resolve(stats); + } + }; + + void update?.().catch(error => complete?.(error)); + }); + + const initial = next(false); + const watcher = compiler.watch({poll: 50}, (error, stats) => complete?.(error, stats)); + + try { + let stats = await initial; + const identities = new Map(); + let previousIsolatedCss: string | undefined; + + for (const [index, state] of ["none", "shadow", "none", "shadow", "none"].entries()) { + const isolated = state === "shadow"; + + if (index === 1) { + // Change the input after compilation, before its watch callback: that result is stale. + stats = await next(isolated, async () => { + queuedState = state; + watcher.invalidate(); + }); + } else if (index > 1) { + stats = await next(isolated, () => setState(state)); + } + + const {compilation} = stats; + + for (const order of orders) { + const module = [...compilation.modules].find( + module => + (module as NormalModule).resource === path.join(fixture, `${order}.js`) && + module.layer === getContentLayer(ContentScriptWorld.Isolated) + )!; + const id = compilation.chunkGraph.getModuleId(module); + + if (identities.has(order)) { + expect(id).toBe(identities.get(order)); + } + + identities.set(order, id); + const chunks = compilation.chunkGraph.getModuleChunks(module); + const lazy = chunks.find(chunk => [...chunk.groupsIterable].some(group => !group.isInitial()))!; + const group = [...lazy.groupsIterable].find(group => !group.isInitial())!; + expect( + group.chunks.flatMap(chunk => [...chunk.files].filter(file => file.endsWith(".css"))) + ).toHaveLength(isolated ? 2 : 1); + } + + const runtime = compilation.entrypoints + .get("shadow")! + .getFiles() + .filter(file => file.endsWith(".js")) + .map(file => compilation.getAsset(file)!.source.source().toString()) + .join("\n"); + expect(runtime.includes("loadDocumentStylesheet")).toBe(isolated); + expect(runtime.includes("isolatedStyleRoots")).toBe(isolated); + const entry = compilation.entrypoints.get("shadow-document-first")!; + const files = entry.getFiles(); + const manifest = JSON.parse(compilation.getAsset("manifest.json")!.source.source().toString()); + const script = manifest.content_scripts.find((script: {js: string[]}) => + script.js.some(file => files.includes(file)) + ); + const documentCss = script.css + .map((file: string) => compilation.getAsset(file)!.source.source().toString()) + .join("\n"); + expect(documentCss.includes("--split-ui:")).toBe(!isolated); + const war = manifest.web_accessible_resources.flatMap((rule: {resources: string[]}) => rule.resources); + + if (isolated) { + previousIsolatedCss = files.find( + file => + file.endsWith(".css") && + compilation.getAsset(file)!.source.source().toString().includes("--split-ui:") + ); + expect(war).toContain(previousIsolatedCss); + } else if (previousIsolatedCss) { + expect(war).not.toContain(previousIsolatedCss); + } + } + } finally { + await new Promise(resolve => watcher.close(resolve)); + await closeCompiler(compiler); + await rm(output, {recursive: true, force: true}); + } +}, 30_000); diff --git a/tests/integration/build/style-routing/selective-split/README.md b/tests/integration/build/style-routing/selective-split/README.md new file mode 100644 index 00000000..e841bcb9 --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/README.md @@ -0,0 +1,49 @@ +# Selective CSS split feasibility check + +The initial feasibility probe is now a regression fixture for production style rules, +`ChunkLoaderPlugin`, `IsolatedStylesPlugin`, asset maps and manifest generation. The isolated +styles plugin owns the entry-selective `adnbnDocumentStyles` group; the style plugin only +classifies CSS and supplies loaders. No emitted source is patched. + +Both consumers use one JavaScript world layer and the same dynamic-import call sites. Each +lazy module remains one physical JS chunk shared by normal and shadow consumers. Separate +modules cover document-first and document-last imports without a forced chunk name. + +The build suite also covers ordinary initial popup/options/page/content/Relay entries beside +shadow entries, both import orders, named UI CSS, manifest/WAR and a repeated none/shadow/none +watch cycle. The actual CLI watch suite exercises the shared-lazy transition from entrypoint +options. Separate isolated-styles and asset-map tests cover differing initial/async filename +templates, hash placeholders and callbacks. + +## Observed boundary + +With Rspack 1.7.11, splitting the shared lazy chunk inserts its document CSS chunk before +its original UI CSS chunk. Both source import orders therefore produce: + +- In the ordinary document: `?unisolated` CSS first, UI CSS second. The equal-specificity + UI rule wins (blue), including when document CSS was imported last in the source. +- In ShadowRoot: only UI CSS. The document-only border does not leak into the root. +- The same result whether normal or shadow requests the shared module first. The import + continuation observes applied CSS, and the second consumer does not duplicate document links. + +This is a constraint of sharing the physical chunk, not a general query-priority contract. +When neither entry selects isolated delivery, the control build emits one lazy CSS file +with both categories in their source import order for this fixture. + +Browser measurements passed on Chrome 156.0.8061.0 MV3 and Firefox 156.0 MV2/MV3. Each browser +test checks both import orders and both first-consumer orders (four cases per target). +Reports including computed styles and link order are written to +`.cache/integration/selective-split--mv.json`. + +## Reproduce + +```sh +node scripts/test-run.mjs --selectProjects build chrome firefox --testPathPatterns=selective-split --runInBand +``` + +Browser checks also measure the initial ordinary content cascade in both source orders: blue +for document-first and red for document-last. This asserts the fixture's normal bundler ordering, +not a general guarantee that CSS always follows JavaScript source order across arbitrary graphs. + +This bounded fixture does not test iframe recovery, React rendering, CSP/fonts or performance. +Those runtime behaviors remain covered by the separate isolation suites; this is not a benchmark. diff --git a/tests/integration/build/style-routing/selective-split/compiler.ts b/tests/integration/build/style-routing/selective-split/compiler.ts new file mode 100644 index 00000000..8b538f8f --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/compiler.ts @@ -0,0 +1,156 @@ +import path from "path"; +import fs from "fs"; +import {rspack, type Filename, type Configuration, type Compilation, type Compiler, type Stats} from "@rspack/core"; + +import {getContentLayer} from "@cli/bundler/layers"; +import ChunkLoaderPlugin from "@cli/bundler/plugins/chunk-loader"; +import IsolatedStylesPlugin from "@cli/bundler/plugins/isolated-styles"; +import ManifestPlugin from "@cli/bundler/plugins/manifest"; +import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; +import ManifestV3 from "@cli/builders/manifest/ManifestV3"; +import {Browser} from "@typing/browser"; +import {GenerateModulePlugin} from "@cli/bundler/plugins/generate-module"; +import {createRuntimeModule} from "@cli/bundler/plugins/utils"; +import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "@cli/plugins/output/runtime"; +import stylePlugin from "@cli/plugins/style"; +import {ContentScriptStylesRuntimeProperty, ContentScriptWorld} from "@typing/content"; +import type {ReadonlyConfig} from "@typing/config"; + +export const project = path.resolve(__dirname, "../../../../.."); +export const fixture = path.join(__dirname, "src"); +export const orders = ["document-first", "document-last"] as const; +export const watchSelections = new WeakMap(); + +interface CompilerOptions { + initial?: boolean; + cssFilename?: Filename; + selectionFile?: string; +} + +export const createCompiler = async ( + output: string, + isolated = true, + options: CompilerOptions = {} +): Promise => { + const cssFilename = options.cssFilename ?? "css/[name].[contenthash:8].css"; + const cssChunkFilename = cssFilename; + const config = { + rootDir: project, + app: "selective-split", + mergeStyles: false, + cssDir: "", + cssFilename, + cssIdentName: "[local]", + } as ReadonlyConfig; + + const handler = stylePlugin().bundler!; + const styles = (typeof handler === "function" ? await handler({config, rspack: {}}) : handler) as Configuration; + const layer = getContentLayer(ContentScriptWorld.Isolated); + let isolatedDelivery = isolated; + const selected = (entry: string) => isolatedDelivery && entry.startsWith("shadow"); + const initialEntries = options.initial + ? Object.fromEntries( + orders.flatMap(order => + ["popup", "options", "page", "content", "relay", "shadow"].map(type => [ + `${type}-${order}`, + {import: `./${order}.js`, ...(["content", "relay", "shadow"].includes(type) ? {layer} : {})}, + ]) + ) + ) + : {}; + + return rspack({ + context: fixture, + mode: "production", + devtool: false, + entry: {normal: {import: "./entry.js", layer}, shadow: {import: "./entry.js", layer}, ...initialEntries}, + output: { + path: output, + filename: "js/[name].[contenthash:8].js", + chunkFilename: "js/[name].[contenthash:8].js", + publicPath: "", + uniqueName: "selectiveSplitProbe", + chunkLoadTimeout: 5_000, + }, + resolve: {modules: [path.join(project, "node_modules")]}, + resolveLoader: {modules: [path.join(project, "node_modules")]}, + module: styles.module, + optimization: { + minimize: false, + concatenateModules: false, + moduleIds: "deterministic", + chunkIds: "deterministic", + splitChunks: { + cacheGroups: { + default: false, + defaultVendors: false, + ...(styles.optimization?.splitChunks ? styles.optimization.splitChunks.cacheGroups : {}), + }, + }, + }, + plugins: [ + { + apply(compiler) { + const file = options.selectionFile; + + if (!file) { + return; + } + + compiler.hooks.watchRun.tap("SelectiveSplitSelection", () => { + isolatedDelivery = JSON.parse(fs.readFileSync(file, "utf8")).isolation !== "none"; + }); + + compiler.hooks.thisCompilation.tap("SelectiveSplitSelection", compilation => { + watchSelections.set(compilation, isolatedDelivery); + compilation.fileDependencies.add(file); + }); + }, + }, + ...styles.plugins!, + new GenerateModulePlugin({ + [RuntimeModuleRequest]: createRuntimeModule([RuntimeModuleReaders.contentStyles]), + }), + new ChunkLoaderPlugin({test: entry => entry === "normal" || entry === "shadow"}), + new IsolatedStylesPlugin({ + cssFilename, + cssChunkFilename, + property: ContentScriptStylesRuntimeProperty, + test: selected, + }), + new BuildAssetsMapPlugin({ + module: EntrypointAssetsModule, + fullMapEntrypoint: "background", + cssFilename, + cssChunkFilename, + }), + new ManifestPlugin( + new ManifestV3(Browser.Chrome).setContentScripts( + new Set( + [ + "normal", + "shadow", + ...Object.keys(initialEntries).filter(name => /^(content|relay|shadow)-/.test(name)), + ].map(entry => ({entry, matches: ["https://example.com/*"]})) + ) + ) + ), + ], + }); +}; + +export const runCompiler = (compiler: Compiler): Promise => + new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + resolve(stats); + } + }); + }); + +export const closeCompiler = (compiler: Compiler): Promise => + new Promise((resolve, reject) => { + compiler.close(error => (error ? reject(error) : resolve())); + }); diff --git a/tests/integration/build/style-routing/selective-split/site/initial.html b/tests/integration/build/style-routing/selective-split/site/initial.html new file mode 100644 index 00000000..a6f3439e --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/site/initial.html @@ -0,0 +1,10 @@ + + + + + Initial CSS cascade + + +

Initial CSS order

+ + diff --git a/tests/integration/build/style-routing/selective-split/site/top.html b/tests/integration/build/style-routing/selective-split/site/top.html new file mode 100644 index 00000000..2e9df5ec --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/site/top.html @@ -0,0 +1,8 @@ + + + + + Selective CSS splitting + + + diff --git a/tests/integration/build/style-routing/selective-split/src/document-first.js b/tests/integration/build/style-routing/selective-split/src/document-first.js new file mode 100644 index 00000000..6e88e7ce --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/document-first.js @@ -0,0 +1,4 @@ +import "./document-first/document.scss?unisolated"; +import "./document-first/ui.scss"; + +export const order = "document-first"; diff --git a/tests/integration/build/style-routing/selective-split/src/document-first/document.scss b/tests/integration/build/style-routing/selective-split/src/document-first/document.scss new file mode 100644 index 00000000..82853131 --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/document-first/document.scss @@ -0,0 +1,5 @@ +:global(.cascade-probe) { + color: rgb(204, 0, 0); + border-top: 7px solid currentColor; + --split-document: first; +} diff --git a/tests/integration/build/style-routing/selective-split/src/document-first/ui.scss b/tests/integration/build/style-routing/selective-split/src/document-first/ui.scss new file mode 100644 index 00000000..d9c1220b --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/document-first/ui.scss @@ -0,0 +1,4 @@ +:global(.cascade-probe) { + color: rgb(0, 0, 204); + --split-ui: first; +} diff --git a/tests/integration/build/style-routing/selective-split/src/document-last.js b/tests/integration/build/style-routing/selective-split/src/document-last.js new file mode 100644 index 00000000..55bcb1e9 --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/document-last.js @@ -0,0 +1,4 @@ +import "./document-last/ui.scss"; +import "./document-last/document.scss?unisolated"; + +export const order = "document-last"; diff --git a/tests/integration/build/style-routing/selective-split/src/document-last/document.scss b/tests/integration/build/style-routing/selective-split/src/document-last/document.scss new file mode 100644 index 00000000..ee8d7c13 --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/document-last/document.scss @@ -0,0 +1,5 @@ +:global(.cascade-probe) { + color: rgb(204, 0, 0); + border-top: 7px solid currentColor; + --split-document: last; +} diff --git a/tests/integration/build/style-routing/selective-split/src/document-last/ui.scss b/tests/integration/build/style-routing/selective-split/src/document-last/ui.scss new file mode 100644 index 00000000..16d069ef --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/document-last/ui.scss @@ -0,0 +1,4 @@ +:global(.cascade-probe) { + color: rgb(0, 0, 204); + --split-ui: last; +} diff --git a/tests/integration/build/style-routing/selective-split/src/entry.js b/tests/integration/build/style-routing/selective-split/src/entry.js new file mode 100644 index 00000000..1a09e0ca --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/entry.js @@ -0,0 +1,41 @@ +import {readContentStyles} from "#adnbn/runtime"; + +const styles = readContentStyles(); +const name = styles ? "shadow" : "normal"; +const host = document.createElement("section"); +host.id = `selective-${name}`; +const root = styles ? host.attachShadow({mode: "open"}) : host; +const target = document.createElement("div"); +target.className = "cascade-probe"; +target.textContent = name; +root.append(target); +document.body.append(host); + +if (styles) { + styles.initialize(file => chrome.runtime.getURL(file)); + styles.add(root, target); +} + +const button = document.createElement("button"); +button.id = `load-${name}`; +button.textContent = `Load ${name}`; +document.body.append(button); +button.addEventListener("click", async () => { + host.dataset.state = "loading"; + + try { + const order = new URL(location.href).searchParams.get("order"); + const loaded = + order === "document-first" ? await import("./document-first.js") : await import("./document-last.js"); + const computed = getComputedStyle(target); + host.dataset.completion = JSON.stringify({ + order: loaded.order, + color: computed.color, + border: computed.borderTopWidth, + }); + host.dataset.state = "loaded"; + } catch (error) { + host.dataset.state = "error"; + host.dataset.error = String(error); + } +}); diff --git a/tests/integration/build/style-routing/selective-split/src/package.json b/tests/integration/build/style-routing/selective-split/src/package.json new file mode 100644 index 00000000..43288d05 --- /dev/null +++ b/tests/integration/build/style-routing/selective-split/src/package.json @@ -0,0 +1,5 @@ +{ + "private": true, + "sideEffects": true, + "type": "module" +} diff --git a/tests/integration/build/style-routing/src/child/_tokens.scss b/tests/integration/build/style-routing/src/child/_tokens.scss new file mode 100644 index 00000000..8186c995 --- /dev/null +++ b/tests/integration/build/style-routing/src/child/_tokens.scss @@ -0,0 +1 @@ +$marker: inherited; diff --git a/tests/integration/build/style-routing/src/child/index.js b/tests/integration/build/style-routing/src/child/index.js new file mode 100644 index 00000000..0bbd9c3f --- /dev/null +++ b/tests/integration/build/style-routing/src/child/index.js @@ -0,0 +1,4 @@ +import "fixture-styles/theme.scss"; +import styles from "./styles.module.scss"; + +export const className = styles.child; diff --git a/tests/integration/build/style-routing/src/child/styles.module.scss b/tests/integration/build/style-routing/src/child/styles.module.scss new file mode 100644 index 00000000..d8ea3111 --- /dev/null +++ b/tests/integration/build/style-routing/src/child/styles.module.scss @@ -0,0 +1,5 @@ +@use "./tokens"; + +.child { + --child-style: #{tokens.$marker}; +} diff --git a/tests/integration/build/style-routing/src/dual-lazy.js b/tests/integration/build/style-routing/src/dual-lazy.js new file mode 100644 index 00000000..d7bfd08f --- /dev/null +++ b/tests/integration/build/style-routing/src/dual-lazy.js @@ -0,0 +1,4 @@ +import isolated from "./dual-lazy.module.scss"; +import document from "./dual-lazy.module.scss?unisolated"; + +export const classes = {isolated: isolated.dual, document: document.dual}; diff --git a/tests/integration/build/style-routing/src/dual-lazy.module.scss b/tests/integration/build/style-routing/src/dual-lazy.module.scss new file mode 100644 index 00000000..1e94068a --- /dev/null +++ b/tests/integration/build/style-routing/src/dual-lazy.module.scss @@ -0,0 +1,3 @@ +.dual { + --dual-style: shared; +} diff --git a/tests/integration/build/style-routing/src/dual.js b/tests/integration/build/style-routing/src/dual.js new file mode 100644 index 00000000..6458b3c8 --- /dev/null +++ b/tests/integration/build/style-routing/src/dual.js @@ -0,0 +1,9 @@ +import {getEntrypointAssets} from "adnbn"; +import isolated from "./dual.module.scss"; +import document from "./dual.module.scss?unisolated"; + +globalThis.dualClasses = {isolated: isolated.dual, document: document.dual}; + +globalThis.dualAssets = getEntrypointAssets(); + +globalThis.loadDual = () => import("./dual-lazy.js"); diff --git a/tests/integration/build/style-routing/src/dual.module.scss b/tests/integration/build/style-routing/src/dual.module.scss new file mode 100644 index 00000000..1e94068a --- /dev/null +++ b/tests/integration/build/style-routing/src/dual.module.scss @@ -0,0 +1,3 @@ +.dual { + --dual-style: shared; +} diff --git a/tests/integration/build/style-routing/src/lazy.js b/tests/integration/build/style-routing/src/lazy.js index 66425407..9ac9b493 100644 --- a/tests/integration/build/style-routing/src/lazy.js +++ b/tests/integration/build/style-routing/src/lazy.js @@ -1,4 +1,4 @@ -import "./lazy-page.css"; -import "./lazy-panel.css?isolation"; +import "./lazy-page.css?unisolated"; +import "./lazy-panel.css"; export const loaded = true; diff --git a/tests/integration/build/style-routing/src/runtime-libraries.js b/tests/integration/build/style-routing/src/runtime-libraries.js new file mode 100644 index 00000000..278ac1a0 --- /dev/null +++ b/tests/integration/build/style-routing/src/runtime-libraries.js @@ -0,0 +1,8 @@ +import {getUrl} from "@addon-core/browser"; +import {createElement} from "react"; +import {createRoot} from "react-dom/client"; +import {Builder} from "adnbn/entry/content/vanilla"; +import "fixture-styles"; +import "./shared.js"; + +globalThis.runtimeLibraries = {createElement, createRoot, Builder, getUrl}; diff --git a/tests/integration/build/style-routing/src/shared.js b/tests/integration/build/style-routing/src/shared.js index d0433ea0..03b0ccac 100644 --- a/tests/integration/build/style-routing/src/shared.js +++ b/tests/integration/build/style-routing/src/shared.js @@ -1,2 +1,3 @@ -import "./page.css"; -import "./panel.css?isolation"; +import "./page.css?unisolated"; +import "./panel.css"; +import "./child/index.js"; diff --git a/tests/integration/build/style-routing/src/vendor/fixture-styles/index.js b/tests/integration/build/style-routing/src/vendor/fixture-styles/index.js new file mode 100644 index 00000000..984fb156 --- /dev/null +++ b/tests/integration/build/style-routing/src/vendor/fixture-styles/index.js @@ -0,0 +1,3 @@ +import "./theme.scss"; + +export const name = "fixture-styles"; diff --git a/tests/integration/build/style-routing/src/vendor/fixture-styles/package.json b/tests/integration/build/style-routing/src/vendor/fixture-styles/package.json new file mode 100644 index 00000000..b39fc6fd --- /dev/null +++ b/tests/integration/build/style-routing/src/vendor/fixture-styles/package.json @@ -0,0 +1,8 @@ +{ + "name": "fixture-styles", + "version": "1.0.0", + "exports": { + ".": "./index.js", + "./theme.scss": "./theme.scss" + } +} diff --git a/tests/integration/build/style-routing/src/vendor/fixture-styles/theme.scss b/tests/integration/build/style-routing/src/vendor/fixture-styles/theme.scss new file mode 100644 index 00000000..04ee65fe --- /dev/null +++ b/tests/integration/build/style-routing/src/vendor/fixture-styles/theme.scss @@ -0,0 +1,3 @@ +.vendor { + --vendor-style: inherited; +} diff --git a/tests/integration/build/style-routing/style-routing.integration.test.ts b/tests/integration/build/style-routing/style-routing.integration.test.ts index 86dc6e20..6f5f8238 100644 --- a/tests/integration/build/style-routing/style-routing.integration.test.ts +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -1,7 +1,8 @@ import fs from "fs"; import os from "os"; import path from "path"; -import {rspack, type Configuration, type RuleSetCondition, type Stats} from "@rspack/core"; +import vm from "vm"; +import {rspack, type Configuration, type Stats} from "@rspack/core"; import stylePlugin from "@cli/plugins/style"; import assetPlugin from "@cli/plugins/asset"; import optimizationPlugin from "@cli/plugins/optimization"; @@ -11,7 +12,7 @@ import BuildAssetsMapPlugin from "@cli/bundler/plugins/build-assets-map"; import {createRuntimeModule, getCompilationBuildAssets} from "@cli/bundler/plugins/utils"; import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "@cli/plugins/output/runtime"; import {GenerateModulePlugin} from "@cli/bundler/plugins/generate-module"; -import {getContentLayer, isContentLayer} from "@cli/bundler/layers"; +import {getContentLayer} from "@cli/bundler/layers"; import ManifestPlugin from "@cli/bundler/plugins/manifest"; import ManifestV3 from "@cli/builders/manifest/ManifestV3"; import type {ReadonlyConfig} from "@typing/config"; @@ -23,12 +24,14 @@ test.each<{ commonChunks: boolean; routing: string; layer: string; - isolationIssuerLayer?: RuleSetCondition; + isolatedStyles: boolean; withoutMap?: boolean; + cssFilename?: string; }>([ ...[ {browser: Browser.Chrome, commonChunks: true, withoutMap: true}, {browser: Browser.Chrome, commonChunks: false}, + {browser: Browser.Chrome, commonChunks: true, cssFilename: "css/[contenthash:8].css"}, {browser: Browser.Chromium, commonChunks: true}, {browser: Browser.Edge, commonChunks: true}, {browser: Browser.Firefox, commonChunks: true}, @@ -38,19 +41,20 @@ test.each<{ ...options, routing: "content", layer: getContentLayer(ContentScriptWorld.Isolated), - isolationIssuerLayer: isContentLayer, + isolatedStyles: true, })), { browser: Browser.Chrome, commonChunks: true, routing: "main", layer: getContentLayer(ContentScriptWorld.Main), - isolationIssuerLayer: isContentLayer, + isolatedStyles: true, }, { browser: Browser.Chrome, commonChunks: true, routing: "disabled", + isolatedStyles: false, layer: getContentLayer(ContentScriptWorld.Isolated), }, { @@ -58,13 +62,21 @@ test.each<{ commonChunks: true, routing: "custom", layer: "fixture:ui", - isolationIssuerLayer: /^fixture:ui$/, + isolatedStyles: true, }, ])( "production CSS routing preserves entry assets and CSS/WAR ($browser, commonChunks=$commonChunks, routing=$routing)", - async ({browser, commonChunks, layer, isolationIssuerLayer, withoutMap}) => { + async ({ + browser, + commonChunks, + layer, + isolatedStyles, + withoutMap, + cssFilename = "css/[name].[contenthash:8].css", + }) => { const root = path.resolve(__dirname, "../../../.."); const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-css-routing-")); + const config = { rootDir: path.join(__dirname, "src"), app: "test", @@ -72,26 +84,31 @@ test.each<{ commonChunks, mergeStyles: false, cssDir: "css", - cssFilename: "[name].[contenthash:8].css", + cssFilename: cssFilename.slice(4), cssIdentName: "[local]", assetsDir: "assets", assetsFilename: "[name].[contenthash:8][ext]", } as ReadonlyConfig; - const isolatedStyles = isolationIssuerLayer !== undefined; - const styleHandler = stylePlugin({isolationIssuerLayer}).bundler!; + + const styleHandler = stylePlugin().bundler!; const assetHandler = assetPlugin().bundler!; + const styles = ( typeof styleHandler === "function" ? await styleHandler({config, rspack: {}}) : styleHandler ) as Configuration; + const assets = ( typeof assetHandler === "function" ? await assetHandler({config, rspack: {}}) : assetHandler ) as Configuration; + const optimizationHandler = optimizationPlugin().bundler!; + const optimization = ( typeof optimizationHandler === "function" ? await optimizationHandler({config, rspack: {}}) : optimizationHandler ) as Configuration; + const compiler = rspack({ context: path.join(__dirname, "src"), mode: "production", @@ -99,6 +116,8 @@ test.each<{ entry: { background: withoutMap ? "./background-unused.js" : "./background.js", popup: "./runtime.entry.js", + normal: {import: "./runtime.entry.js", layer: getContentLayer(ContentScriptWorld.Isolated)}, + dual: {import: "./dual.js", layer}, assets: "./assets.js", other: "./other-assets.js", content: {import: "./runtime.entry.js", layer}, @@ -106,7 +125,10 @@ test.each<{ }, output: {...assets.output, path: output, filename: "js/[name].[chunkhash:8].js", publicPath: ""}, resolveLoader: {modules: [path.join(root, "node_modules")]}, - resolve: {alias: {adnbn$: path.join(root, "dist/index.js")}}, + resolve: { + alias: {adnbn$: path.join(root, "dist/index.js")}, + modules: [path.join(__dirname, "src/vendor"), "node_modules"], + }, module: {rules: [...styles.module!.rules!, ...assets.module!.rules!]}, optimization: { ...merge(optimization, styles).optimization, @@ -119,10 +141,10 @@ test.each<{ ...styles.plugins!, ...assets.plugins!, new IsolatedStylesPlugin({ - cssFilename: "css/[name].[contenthash:8].css", - cssChunkFilename: "css/[name].[contenthash:8].css", + cssFilename, + cssChunkFilename: cssFilename, property: ContentScriptStylesRuntimeProperty, - test: entry => entry === "content", + test: entry => isolatedStyles && ["content", "relay", "dual"].includes(entry), }), new GenerateModulePlugin({ [RuntimeModuleRequest]: createRuntimeModule(Object.values(RuntimeModuleReaders)), @@ -130,39 +152,72 @@ test.each<{ new BuildAssetsMapPlugin({ module: EntrypointAssetsModule, fullMapEntrypoint: "background", - cssFilename: "css/[name].[contenthash:8].css", - cssChunkFilename: "css/[name].[contenthash:8].css", + cssFilename, + cssChunkFilename: cssFilename, }), new ManifestPlugin( new ManifestV3(browser).setContentScripts( new Set([ {entry: "content", matches: ["https://example.com/*"]}, {entry: "relay", matches: ["https://example.com/*"]}, + {entry: "dual", matches: ["https://example.com/*"]}, + {entry: "normal", matches: ["https://example.com/*"]}, ]) ) ), ], }); + try { const stats = await new Promise((resolve, reject) => compiler.run((error, stats) => { - if (error || !stats || stats.hasErrors()) + if (error || !stats || stats.hasErrors()) { reject(error ?? new Error(stats?.toString({all: false, errors: true}))); - else resolve(stats); + } else { + resolve(stats); + } }) ); + const map = getCompilationBuildAssets(stats.compilation)!; + + const execute = (files: readonly string[]) => { + const context = vm.createContext({}); + vm.runInContext("self = globalThis", context); + + for (const file of files) { + vm.runInContext(stats.compilation.getAsset(file)!.source.source().toString(), context); + } + + return context; + }; + + expect(execute(map.dual.initial.js).dualAssets).toEqual({initial: map.dual.initial, async: map.dual.async}); + + if (!withoutMap) { + expect(execute(map.background.initial.js).assets).toEqual(map); + } + for (const asset of stats.compilation.getAssets().filter(asset => asset.name.endsWith(".js"))) { const source = asset.source.source().toString(); - expect(source).not.toContain("__adnbnBuildAssetsCurrentMap__"); - if (withoutMap) expect(source).not.toContain("__adnbnBuildAssetsFullMap__"); + + if (!map.dual.initial.js.includes(asset.name)) { + expect(source).not.toContain("__adnbnBuildAssetsCurrentMap__"); + } + + if (withoutMap) { + expect(source).not.toContain("__adnbnBuildAssetsFullMap__"); + } } + if (isolatedStyles) { const source = map.content.initial.js .map(file => stats.compilation.getAsset(file)!.source.source().toString()) .join("\n"); + expect(source).toContain(ContentScriptStylesRuntimeProperty); } + expect(map.assets.assets).toEqual( expect.arrayContaining([ expect.stringMatching(/assets\/css-font\.[a-f0-9]+\.woff2$/), @@ -172,18 +227,24 @@ test.each<{ expect.stringMatching(/assets\/document\.[a-f0-9]+\.custom$/), ]) ); + expect(map.assets.assets.some(file => file.includes("inline") || /\.(js|json)$/.test(file))).toBe(false); expect(map.other.assets.some(file => file.includes("inline"))).toBe(true); + const css = map.assets.initial.css .map(file => stats.compilation.getAsset(file)!.source.source()) .join("\n"); + const scheme = browser === Browser.Firefox ? "moz-extension" : "chrome-extension"; + for (const name of ["css-font", "image", "movie"]) { const file = map.assets.assets.find(file => file.startsWith(`assets/${name}.`))!; // Both ?browser and ?chrome opt into an extension URL for the build target. expect(css).toContain(`${scheme}://__MSG_@@extension_id__/${file}`); } + expect(css).toContain("data:"); + for (const entry of [map.content, map.relay]) { expect(entry.initial.js.some(file => file.includes("common"))).toBe(commonChunks); expect(Object.keys(entry.initial).sort()).toEqual(["css", "js"]); @@ -193,29 +254,77 @@ test.each<{ expect(entry.initial.js).not.toEqual(expect.arrayContaining(map.background.initial.js)); } - // The same ?isolation import stays ordinary CSS outside the selected issuer layers. + // Outside selected entries both CSS categories use ordinary document delivery. expect(map.popup.initial.css).toHaveLength(1); expect(map.popup.async.css).toHaveLength(1); - const popupCss = stats.compilation.getAsset(map.popup.initial.css[0])!.source.source().toString(); + const popupCss = map.popup.initial.css + .map(file => stats.compilation.getAsset(file)!.source.source().toString()) + .join("\n"); expect(popupCss).toMatch(/color:\s*red/); expect(popupCss).toMatch(/color:\s*blue/); + const manifest: chrome.runtime.ManifestV3 = JSON.parse( stats.compilation.getAsset("manifest.json")!.source.source().toString() ); + const panel = map.content.initial.css.filter(file => /color:\s*blue/.test(stats.compilation.getAsset(file)!.source.source().toString()) ); + expect(panel).toHaveLength(1); const isolated = isolatedStyles ? panel : []; + if (isolatedStyles) { expect(stats.compilation.getAsset(panel[0])!.source.source().toString()).not.toMatch(/color:\s*red/); + + expect(stats.compilation.getAsset(panel[0])!.source.source().toString()).toContain( + "--child-style: inherited" + ); + + expect(stats.compilation.getAsset(panel[0])!.source.source().toString()).toContain(".nested"); + + expect(stats.compilation.getAsset(panel[0])!.source.source().toString()).toContain( + "--vendor-style: inherited" + ); } + expect(manifest.content_scripts?.[0].css).toEqual( map.content.initial.css.filter(file => !isolated.includes(file)) ); - expect(manifest.content_scripts?.[1].css).toEqual(map.relay.initial.css); + + expect(manifest.content_scripts?.[1].css).toEqual( + map.relay.initial.css.filter( + file => + !isolatedStyles || + !/color:\s*blue/.test(stats.compilation.getAsset(file)!.source.source().toString()) + ) + ); + + expect(manifest.content_scripts?.[3].css).toEqual(map.normal.initial.css); + expect(map.normal.initial.css).toHaveLength(1); + const normalCss = map.normal.initial.css + .map(file => stats.compilation.getAsset(file)!.source.source().toString()) + .join("\n"); + expect(normalCss).toContain("--child-style: inherited"); + expect(normalCss).toMatch(/color:\s*blue/); + expect(normalCss).toMatch(/color:\s*red/); + const dualDocument = manifest.content_scripts?.[2].css ?? []; + expect(dualDocument.length).toBeGreaterThan(0); + + expect( + dualDocument.map(file => stats.compilation.getAsset(file)!.source.source().toString()).join("\n") + ).toContain("--dual-style"); + const war = manifest.web_accessible_resources?.flatMap(rule => rule.resources) ?? []; expect(war).toEqual(expect.arrayContaining(isolated)); + + if (cssFilename === "css/[contenthash:8].css") { + expect(map.dual.initial.css).toHaveLength(1); + expect(map.dual.async.css).toEqual([]); + expect(dualDocument).toEqual(map.dual.initial.css); + expect(war).toEqual(expect.arrayContaining(dualDocument)); + } + expect(war).toEqual(expect.arrayContaining(map.content.async.css)); } finally { await new Promise((resolve, reject) => compiler.close(error => (error ? reject(error) : resolve()))); diff --git a/tests/integration/types/content.integration.test.ts b/tests/integration/types/content.integration.test.ts index 30df98a6..d2317eb4 100644 --- a/tests/integration/types/content.integration.test.ts +++ b/tests/integration/types/content.integration.test.ts @@ -31,6 +31,7 @@ describe("content contracts", () => { const program = ts.createProgram( [ fixture, + path.join(__dirname, "fixtures/content/styles.ts"), watchFixture, path.join(__dirname, "fixtures/content/prepare.tsx"), path.join(__dirname, "fixtures/content/target.tsx"), diff --git a/tests/integration/types/fixtures/content/prepare.tsx b/tests/integration/types/fixtures/content/prepare.tsx index 820f1127..10c864a1 100644 --- a/tests/integration/types/fixtures/content/prepare.tsx +++ b/tests/integration/types/fixtures/content/prepare.tsx @@ -1,5 +1,5 @@ import {ContentScriptAppend, defineContentScript, defineContentScriptAppend, defineRelay} from "adnbn"; -import type {ContentScriptMount, ContentScriptProps, ContentScriptRenderReactComponent} from "adnbn"; +import type {ContentScriptMount, ContentScriptNode, ContentScriptProps, ContentScriptRenderReactComponent} from "adnbn"; const service = {get: async () => ({title: "Product", visible: true})}; @@ -115,3 +115,10 @@ function useMount(node: ContentScriptMount): void { const mounted: boolean | undefined | void = node.mount(); const unmounted: boolean | undefined | void = node.unmount(); } + +function useNode(node: ContentScriptNode): void { + // Context nodes retain the same synchronous command contract as other mountables. + useMount(node); + const mounted: boolean | undefined | void = node.mount(); + const unmounted: boolean | undefined | void = node.unmount(); +} diff --git a/tests/integration/types/fixtures/content/styles.module.scss b/tests/integration/types/fixtures/content/styles.module.scss new file mode 100644 index 00000000..bdf81bbf --- /dev/null +++ b/tests/integration/types/fixtures/content/styles.module.scss @@ -0,0 +1,3 @@ +.panel { + color: red; +} diff --git a/tests/integration/types/fixtures/content/styles.ts b/tests/integration/types/fixtures/content/styles.ts new file mode 100644 index 00000000..16dd7581 --- /dev/null +++ b/tests/integration/types/fixtures/content/styles.ts @@ -0,0 +1,6 @@ +import "adnbn/client-types"; +import styles from "./styles.module.scss?unisolated"; + +const name: string = styles.panel; +// @ts-expect-error CSS Modules exports are readonly. +styles.panel = name; diff --git a/tests/integration/types/fixtures/content/watch.ts b/tests/integration/types/fixtures/content/watch.ts index 9c985926..6be618cb 100644 --- a/tests/integration/types/fixtures/content/watch.ts +++ b/tests/integration/types/fixtures/content/watch.ts @@ -17,6 +17,7 @@ const watch: ContentScriptWatchStrategy = createMutationObserverStrategy({ const callback: ContentScriptEventCallback = (event, node) => { const contentNode: ContentScriptNode = node; const rootEvent: import("adnbn").ContentScriptEvent = event; + if (rootEvent === ContentScriptEvent.Mount) { contentNode.container?.setAttribute("data-mounted", "true"); } @@ -35,15 +36,36 @@ defineContentScript({ defineContentScriptAppend({watch: createAwaitFirstStrategy({attributes: true}), render: () => "Content"}); const custom: ContentScriptWatchStrategy = (update, context) => { - const observer = new MutationObserver(update); + const observer = new MutationObserver(records => { + if (records.some(record => !context.owns(record.target))) { + void update(); + } + }); observer.observe(document.body, {childList: true}); const unsubscribe = context.watch(callback); + return () => { observer.disconnect(); unsubscribe(); }; }; + defineContentScript({watch: custom}); // @ts-expect-error: Mutation observation uses the platform's typed options. createMutationObserverStrategy({attributeFilter: [1]}); + +// @ts-expect-error: Every custom context must implement the public membership query. +const missingOwnership: ContentScriptContext = { + nodes: new Set(), + mount() {}, + unmount() {}, + watch: () => () => {}, + unwatch() {}, +}; + +const customContext: ContentScriptContext = { + ...missingOwnership, + owns: (_target: Node): boolean => false, +}; +const owned: boolean = customContext.owns(document.createTextNode("UI")); diff --git a/tsup.config.ts b/tsup.config.ts index df87dc20..e24db92f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -68,6 +68,7 @@ const virtualEntrypointImportPlugin = (): Plugin => ({ }); const runtimeTemplateEntries = [ + "src/cli/bundler/plugins/build-assets-map/templates.ts", "src/cli/bundler/plugins/chunk-loader/templates.ts", "src/cli/bundler/plugins/isolated-styles/templates.ts", ]; diff --git a/types/client.d.ts b/types/client.d.ts index 2a73bb04..ff12fd79 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -310,17 +310,17 @@ declare module "*?asis" { export default src; } -declare module "*?isolation" { +declare module "*?unisolated" { const classes: Readonly>; export default classes; } -declare module "*?isolation&asis" { +declare module "*?unisolated&asis" { const src: string; export default src; } -declare module "*?asis&isolation" { +declare module "*?asis&unisolated" { const src: string; export default src; }