diff --git a/.gitignore b/.gitignore index 560b8fd..1d4e2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Test artifacts +coverage/ + # Dependency directories node_modules/ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 235e5e2..99ec061 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -14,6 +14,20 @@ N.B. See changelogs for individual packages, where most change will occur: This log covers the [monorepo](https://en.wikipedia.org/wiki/Monorepo). +## [0.13.2] - ????-??-?? + +### Fixed + +- remove `react` and `react-dom` from repo root package.json, introduced in [version 0.9.0](#090---2024-11-29) + +### Added + +- `test:unit:coverage` script + +### Changed + +- set minimum node version to 20.8 due to use of [`import.meta.resolve`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta/resolve) where possible in packages / examples + ## [0.13.1] - 2025-11-14 ### Changed diff --git a/docs/README.md b/docs/README.md index f8be1d5..dcd3416 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,16 +51,18 @@ The bare minimum would be injecting a plugin from the [`webpack package`](../pac Then: -1. Figure out what code modules you'd like to toggle, and see if they are suitable -2. Figure out a filesystem convention to use +1. Figure out what code modules you'd like to toggle. +2. Figure out a filesystem convention to use. - the default proposes colocating variations of base modules in `./__variants__///` folders, with filename parity for the modules themselves. -2. Implement the [`webpack package`](../packages/webpack/docs/README.md)[^2] into your build process, via configuration of appropriate [`pointcuts`](https://en.wikipedia.org/wiki/Pointcut), targeting code modules for toggling that meet the criteria: - - A single, default export, that is a function - - Side-effect free (or, at least, with harmless import side-effects) - - Resolvable by Webpack -3. Create a feature toggle state store, utilising the [`features package`](../packages/features/docs/README.md), or otherwise, suitable for the dynamism of your toggle type. +3. Figure out a ["loading strategy"](../packages/webpack/docs/README.md#loadstrategy) to use. + - the default loads modules at the point they are selected for use, and the code is build into the entry point of the application. + - static (loaded at the entrypoint to the bundle holding the code) or asynchronous (code split) strategies are also available, if compatible with the referring code +4. Implement the [`webpack package`](../packages/webpack/docs/README.md)[^2] into your build process, via configuration of appropriate [`pointcuts`](https://en.wikipedia.org/wiki/Pointcut), targeting code modules for toggling that meet the criteria: + - A single, default export, that is a function. + - Resolvable by Webpack. +5. Create a feature toggle state store, utilising the [`features package`](../packages/features/docs/README.md), or otherwise, suitable for the dynamism of your toggle type. - This needs to get state from an appropriate Toggle Router / runtime state provider. -4. Create a toggle point for the point cuts +6. Create a toggle point for the point cuts. - For a [React](https://react.dev/) application, the `withTogglePointFactory` or `withToggledHookFactory` from the [`react pointcuts package`](../packages/react-pointcuts/docs/README.md) can be used, to construct one. - Again, use [the examples](../examples/README.md) as a guide. diff --git a/examples/express/docs/CHANGELOG.md b/examples/express/docs/CHANGELOG.md index 1937dc2..18cb12d 100644 --- a/examples/express/docs/CHANGELOG.md +++ b/examples/express/docs/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - ????-??-?? + +### Changed + +- added `source-map` devtool and `source-map-loader` to add in visualisation of the module structure in browser developer tools +- fixed to exact version of `react` in `dependencies`, and brought in version-linked `react-is`, a new required peer dependency of the `react-pointcuts` package +- updated the `config` example to utilise the `lazyComponentLoadStrategyFactory` from the `react-pointcuts` package +- updated the `animals` example to utilise the `staticLoadStrategyFactory` from the `webpack` package +- updated `webpack` to `5.99.7` +- update to support new object argument for toggle points introduced by updated webpack plugin +- `MiniCssExtractPlugin` moved to "common" setup block when configuring point cut +- updated to use [`output.module`](https://webpack.js.org/configuration/output/#outputmodule), to help demonstrate this compatibility + - updated [`webpack-node-externals`](https://www.npmjs.com/package/webpack-node-externals) to use `module` [`importType`](https://www.npmjs.com/package/webpack-node-externals#optionsimporttype-commonjs) + ## [0.4.0] - 2025-10-21 ### Changed diff --git a/examples/express/package.json b/examples/express/package.json index 0bf68b1..fdcba13 100644 --- a/examples/express/package.json +++ b/examples/express/package.json @@ -1,6 +1,6 @@ { "name": "web-toggle-point-express-example", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "engines": { "node": ">=20.6.0" @@ -10,9 +10,9 @@ "build-dependencies": "path-exists ../../packages/webpack/lib || npm run --prefix ../../packages/webpack build && path-exists ../../packages/ssr/lib || npm run --prefix ../../packages/ssr build && path-exists ../../packages/features/lib || npm run --prefix ../../packages/features build && path-exists ../../packages/react-pointcuts/lib || npm run --prefix ../../packages/react-pointcuts build", "prebuild": "npm run build-dependencies", "build": "webpack", - "start": "cross-env PORT=3002 node bin/server.cjs", - "start:small-env": "cross-env PORT=3003 node --env-file=./src/routes/config/.env-small bin/server.cjs", - "start:large-env": "cross-env PORT=3004 node --env-file=./src/routes/config/.env-large bin/server.cjs", + "start": "cross-env PORT=3002 node bin/server.mjs", + "start:small-env": "cross-env PORT=3003 node --env-file=./src/routes/config/.env-small bin/server.mjs", + "start:large-env": "cross-env PORT=3004 node --env-file=./src/routes/config/.env-large bin/server.mjs", "prelint": "npm run build-dependencies", "lint": "npm run lint:code && npm run lint:docs", "lint:fix": "npm run lint:code -- --fix && npm run lint:docs -- --fix", @@ -28,9 +28,11 @@ "cross-env": "^7.0.3", "express": "^4.17.1", "http-status-codes": "^2.3.0", - "react": ">=17", - "react-dom": ">=17", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-is": "^18.3.1", "react-redux": "^9.2.0", + "source-map-loader": "^5.0.0", "valtio": "^2.1.5" }, "devDependencies": { @@ -43,7 +45,7 @@ "source-map-loader": "^5.0.0", "style-loader": "^4.0.0", "ts-loader": "^9.5.2", - "webpack": "^5.38.1", + "webpack": "^5.99.7", "webpack-cli": "^4.7.2", "webpack-node-externals": "^3.0.0" }, diff --git a/examples/express/src/index.js b/examples/express/src/index.js index 1594241..a91c19f 100644 --- a/examples/express/src/index.js +++ b/examples/express/src/index.js @@ -39,7 +39,7 @@ app.get("/", (_, response) => { diff --git a/examples/express/src/routes/animals/README.md b/examples/express/src/routes/animals/README.md index 3d2386d..d750ca2 100644 --- a/examples/express/src/routes/animals/README.md +++ b/examples/express/src/routes/animals/README.md @@ -19,7 +19,11 @@ It also demonstrates the addition of a toggle-specific side-effect, resulting in You should see a picture of a cat or a dog, depending on the version chosen. The contrived application uses a `streamImage` module that accesses a `urlFetcher`, which is varied by the toggle point. -The toggle point is a higher-order function to ensure that each invocation honours the toggle decision, based on current context. As a caveat of toggling, the constructor of the `urlFetcher` must be called on each request, rather than statically enacted at application start-up. The code demonstrates a mitigation of hypothetical cost of re-construction via use of a cache within the toggle point. +## Implementation + +The variant module loading mode is configured as `static`, meaning all variations are added to the module instance cache when the application starts. + +The toggle point is a higher-order function to ensure that each invocation honours the toggle decision, based on current context, forwarding on arguments to a class constructor. As a caveat of `static` toggling, the constructor of the `urlFetcher` must be called on each request, rather than statically enacted at application start-up. The code demonstrates a mitigation of hypothetical cost of re-construction via use of a cache within the toggle point. The toggle point wraps the varied modules in a [`Proxy`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy) to add the logging side-effect. The use of [AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage) allows the toggle decision to be scoped on a per-request basis, without explicit access to the express request context. The storage is initialised within [middleware](https://expressjs.com/en/resources/middleware.html). diff --git a/examples/express/src/routes/animals/togglePoint.js b/examples/express/src/routes/animals/togglePoint.js index 5a0d73d..03998c0 100644 --- a/examples/express/src/routes/animals/togglePoint.js +++ b/examples/express/src/routes/animals/togglePoint.js @@ -2,7 +2,7 @@ import featuresStore from "./featuresStore.js"; const cache = new WeakMap(); -export default (_, featuresMap) => { +export default ({ featuresMap }) => { return function (...args) { const { default: Choice } = featuresMap.get( `v${featuresStore.getFeatures().version}` diff --git a/examples/express/src/routes/config/README.md b/examples/express/src/routes/config/README.md index 964acf2..1730d03 100644 --- a/examples/express/src/routes/config/README.md +++ b/examples/express/src/routes/config/README.md @@ -8,6 +8,8 @@ It shows how a payload can be included in the props passed to the toggle point c N.B. No implication that this is in any way a _good use_ of "config", it's heavily contrived. +The implementation uses the `lazyComponentLoadStrategyFactory` from the `react-pointcuts` package, to ensure that variant code is bundled independently, and downloaded on demand. + ## Setup 1. `npm install` @@ -15,5 +17,10 @@ N.B. No implication that this is in any way a _good use_ of "config", it's heavi 3. open `localhost:3002/config` in a browser, you should see a medium sized div 4. stop, and re-start the server with `npm run start:small-env` or `npm run start:large-env` 5. open `localhost:3003/config` (small env) or `localhost:3004/config` (large env), and see a different sized (and coloured) `div` shown -6. press the buttons, to demonstrate overriding the initial content serialized on the server. +6. press the buttons, to demonstrate overriding the initial content serialized on the server - N.B. The colourisation is only a result of the stored config, so using the buttons will just change the size + - watch the network tab in developer tools to observe the lazy loading in effect + - try blocking one of the subsequent chunks, you should see the error boundary falling back to the default experience, with a console log: + ``` + ChunkLoadError: Variant errored, rendering fallback: Loading chunk ### failed. + ``` diff --git a/examples/express/src/routes/config/featuresStore.js b/examples/express/src/routes/config/featuresStore.js index be2693d..52c205c 100644 --- a/examples/express/src/routes/config/featuresStore.js +++ b/examples/express/src/routes/config/featuresStore.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-unresolved -- https://github.com/import-js/eslint-plugin-import/issues/1810 import featuresStoreFactory from "@asos/web-toggle-point-features/storeFactories/ssrBackedReactContextFeaturesStoreFactory"; const featuresStore = featuresStoreFactory({ diff --git a/examples/express/src/routes/config/router.js b/examples/express/src/routes/config/router.js index f4b2d8d..4a7c5a0 100644 --- a/examples/express/src/routes/config/router.js +++ b/examples/express/src/routes/config/router.js @@ -31,7 +31,7 @@ router.get("/*", (req, res) => { , { - bootstrapScripts: ["/config/main.js"], + bootstrapModules: ["/config/main.mjs"], onShellReady() { res.statusCode = 200; res.setHeader("Content-type", "text/html"); diff --git a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/pointCutConfig.js b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/pointCutConfig.js index 5c6b44c..0a6c1f2 100644 --- a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/pointCutConfig.js +++ b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/pointCutConfig.js @@ -1,19 +1,21 @@ import { win32, posix } from "path"; +import loadStrategyFactory from "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/staticLoadStrategyFactory"; -const toggleHandler = +const toggleHandlerFactoryModuleSpecifier = "/src/routes/parallel-folder-convention/toggle-plumbing/toggleHandler"; const joinPointResolver = (path) => path.replaceAll(win32.sep, posix.sep).replace(/__variants__\/[^/]+\//, ""); const common = { - toggleHandler, - joinPointResolver + toggleHandlerFactoryModuleSpecifier, + joinPointResolver, + loadStrategy: loadStrategyFactory() }; export default [ { name: "react components", - togglePointModule: + togglePointModuleSpecifier: "/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/reactComponentTogglePoint", variantGlobs: [ "./src/routes/parallel-folder-convention/__variants__/*/components/**/!(*.spec).tsx" @@ -22,7 +24,7 @@ export default [ }, { name: "css modules & constants & redux slices", - togglePointModule: + togglePointModuleSpecifier: "/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/objectProxyTogglePoint", variantGlobs: [ "./src/routes/parallel-folder-convention/__variants__/*/components/**/*.css", @@ -33,7 +35,7 @@ export default [ }, { name: "redux reducer maps", - togglePointModule: + togglePointModuleSpecifier: "/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/reduxReducerMapTogglePoint", variantGlobs: [ "./src/routes/parallel-folder-convention/__variants__/*/state/modules/index.ts" diff --git a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/getRelevantModule.ts b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/getRelevantModule.ts index ab64ae6..a4136c6 100644 --- a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/getRelevantModule.ts +++ b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/getRelevantModule.ts @@ -1,11 +1,11 @@ import { getFeatures } from "../featuresStore"; import { FEATURE_KEY } from "../constants"; -const getRelevantModule = (joinPoint, featuresMap) => { +const getRelevantModule = ({ joinPoint, featuresMap, unpack }) => { const activeFeatures = featuresMap.get(FEATURE_KEY); const { selection } = getFeatures(); const variant = activeFeatures.get(selection); - return variant ?? joinPoint; + return unpack(variant ?? joinPoint); }; export default getRelevantModule; diff --git a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/objectProxyTogglePoint.ts b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/objectProxyTogglePoint.ts index a6a50b3..2b876c7 100644 --- a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/objectProxyTogglePoint.ts +++ b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/objectProxyTogglePoint.ts @@ -1,9 +1,9 @@ import getRelevantModule from "./getRelevantModule"; -const togglePoint = (joinPoint, featuresMap) => { - return new Proxy(joinPoint.default, { +const togglePoint = ({ joinPoint, featuresMap, unpack }) => { + return new Proxy(unpack(joinPoint).default, { get(_, ...rest) { - const newTarget = getRelevantModule(joinPoint, featuresMap); + const newTarget = getRelevantModule({ joinPoint, featuresMap, unpack }); return Reflect.get(newTarget.default, ...rest); } }); diff --git a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/reduxReducerMapTogglePoint.ts b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/reduxReducerMapTogglePoint.ts index 2fc0f7d..e6fefc7 100644 --- a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/reduxReducerMapTogglePoint.ts +++ b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggle-points/reduxReducerMapTogglePoint.ts @@ -1,8 +1,8 @@ import getRelevantModule from "./getRelevantModule"; const togglePoint = - (joinPoint, featuresMap) => + ({ joinPoint, featuresMap, unpack }) => (...args) => - getRelevantModule(joinPoint, featuresMap).default(...args); + getRelevantModule({ joinPoint, featuresMap, unpack }).default(...args); export default togglePoint; diff --git a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggleHandler.ts b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggleHandler.ts index 548ab19..0635098 100644 --- a/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggleHandler.ts +++ b/examples/express/src/routes/parallel-folder-convention/toggle-plumbing/toggleHandler.ts @@ -1,11 +1,12 @@ import { FEATURE_KEY } from "./constants"; -export default ({ togglePoint, joinPoint, variantPathMap }) => { - const variantsMap = new Map(); - for (const key of variantPathMap.keys()) { - const [, feature] = key.match(/\/__variants__\/(.+?)\//); - variantsMap.set(feature, variantPathMap.get(key)); - } - const featuresMap = new Map([[FEATURE_KEY, variantsMap]]); - return togglePoint(joinPoint, featuresMap); -}; +export default ({ togglePoint, pack, unpack }) => + ({ joinPoint, variantPathMap }) => { + const variantsMap = new Map(); + for (const key of variantPathMap.keys()) { + const [, feature] = key.match(/\/__variants__\/(.+?)\//); + variantsMap.set(feature, pack(variantPathMap.get(key))); + } + const featuresMap = new Map([[FEATURE_KEY, variantsMap]]); + return togglePoint({ joinPoint: pack(joinPoint), featuresMap, unpack }); + }; diff --git a/examples/express/webpack.config.js b/examples/express/webpack.config.js index ff20d6e..6a3bceb 100644 --- a/examples/express/webpack.config.js +++ b/examples/express/webpack.config.js @@ -1,21 +1,32 @@ import { resolve, basename, dirname, posix } from "path"; +import { fileURLToPath } from "url"; import externals from "webpack-node-externals"; -import { TogglePointInjection } from "@asos/web-toggle-point-webpack/plugins"; +import { TogglePointInjectionPlugin } from "@asos/web-toggle-point-webpack/plugins"; import MiniCssExtractPlugin from "mini-css-extract-plugin"; -import { fileURLToPath } from "url"; +import staticLoadStrategyFactory from "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/staticLoadStrategyFactory"; +import lazyComponentLoadStrategyFactory from "@asos/web-toggle-point-react-pointcuts/lazyComponentLoadStrategyFactory"; import parallelFolderConventionPointCutConfig from "./src/routes/parallel-folder-convention/toggle-plumbing/pointCutConfig.js"; import { EnhancedTsconfigWebpackPlugin } from "enhanced-tsconfig-paths-webpack-plugin"; import webpack from "webpack"; +const publicPath = resolve(dirname(fileURLToPath(import.meta.url)), "public"); + const configPointCutConfig = { name: "configuration variants", variantGlobs: ["./src/routes/config/__variants__/*/*/*.jsx"], - togglePointModule: "/src/routes/config/togglePoint.js" + togglePointModuleSpecifier: "/src/routes/config/togglePoint.js", + loadStrategy: lazyComponentLoadStrategyFactory() }; const common = { - mode: "production", + mode: "development", devtool: "source-map", + experiments: { + outputModule: true + }, + output: { + module: true + }, module: { rules: [ { @@ -67,20 +78,20 @@ const config = [ { entry: "./src/index.js", target: "node", + ...common, output: { + ...common.output, path: resolve(dirname(fileURLToPath(import.meta.url)), "bin"), - filename: "server.cjs", - clean: true, - chunkFormat: "module" + filename: "server.mjs", + clean: true }, - externals: [externals()], - ...common, + externals: [externals({ importType: "module" })], plugins: [ new webpack.DefinePlugin({ CLIENT: false }), new MiniCssExtractPlugin(), - new TogglePointInjection({ + new TogglePointInjectionPlugin({ pointCuts: [ configPointCutConfig, ...parallelFolderConventionPointCutConfig, @@ -95,7 +106,8 @@ const config = [ ...Array(3).fill(".."), basename(variantPath) ), - togglePointModule: "/src/routes/animals/togglePoint.js" + togglePointModuleSpecifier: "/src/routes/animals/togglePoint.js", + loadStrategy: staticLoadStrategyFactory() } ] }) @@ -104,21 +116,35 @@ const config = [ { entry: "./src/routes/config/client.js", target: "web", + ...common, output: { - path: resolve(dirname(fileURLToPath(import.meta.url)), "public"), - filename: "main.js" + ...common.output, + path: publicPath, + filename: "main.mjs", + clean: true }, plugins: [ new MiniCssExtractPlugin(), - new TogglePointInjection({ pointCuts: [configPointCutConfig] }) + new TogglePointInjectionPlugin({ pointCuts: [configPointCutConfig] }) ], - ...common + module: { + ...common.module, + rules: [ + { + test: /\.js$/, + enforce: "pre", + use: ["source-map-loader"] + }, + ...common.module.rules + ] + } }, { entry: "./src/routes/parallel-folder-convention/client.js", target: "web", + ...common, output: { - path: resolve(dirname(fileURLToPath(import.meta.url)), "public"), + path: publicPath, filename: "parallel-folder-convention.js" }, plugins: [ @@ -128,11 +154,10 @@ const config = [ new MiniCssExtractPlugin({ filename: "parallel-folder-convention.css" }), - new TogglePointInjection({ + new TogglePointInjectionPlugin({ pointCuts: parallelFolderConventionPointCutConfig }) - ], - ...common + ] } ]; diff --git a/examples/next/README.md b/examples/next/README.md index bda15c2..a4a60c6 100644 --- a/examples/next/README.md +++ b/examples/next/README.md @@ -21,3 +21,4 @@ N.B. NextJs support is currently experimental, see [caveats](#caveats). - The webpack package cannot currently vary some of NextJs' [filesystem convention files](https://nextjs.org/docs/pages/getting-started/project-structure#files-conventions) ([Issue #9](https://github.com/ASOS/web-toggle-point/issues/9)) - The `webpack` plugin uses webpack hooks, so is incompatible with the new TurboPack bundler - The `webpack` plugin uses Node JS APIs to access the filesystem, so may be incompatible with [the edge runtime](https://nextjs.org/docs/app/api-reference/edge#unsupported-apis) +- The `webpack` plugin's default loading strategy (`deferredRequire`) is incompatible with the pages router in Next 14 (and presumed below), due to [issues](https://github.com/vercel/next.js/discussions/37520) whereby next converts deferred require into a `Promise`. diff --git a/examples/next/docs/CHANGELOG.md b/examples/next/docs/CHANGELOG.md index e1366c7..c80dcee 100644 --- a/examples/next/docs/CHANGELOG.md +++ b/examples/next/docs/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - ????-??-?? + +### Changed + +- updated to new webpack plugin + - moved "experiments" example to use the `lazyComponentLoadStrategyFactory` from `react-pointcuts` + - new "content management" example utilising the default `deferredRequireLoadStrategyFactory` of `webpack` package +- colocate documentation for "experiments" example to sit with its own `README.mdx` +- updated documentation to indicate incompatibility of Next 14 (and presumed below) with the default `deferredRequireLoadStrategyFactory` +- update [`Next.js`](https://nextjs.org/) to version 16.1.1 + ## [0.4.1] - 2025-10-21 ### Changed diff --git a/examples/next/next-env.d.ts b/examples/next/next-env.d.ts index 830fb59..9edff1c 100644 --- a/examples/next/next-env.d.ts +++ b/examples/next/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/next/next.config.mjs b/examples/next/next.config.mjs index afbacf1..d6014ff 100644 --- a/examples/next/next.config.mjs +++ b/examples/next/next.config.mjs @@ -1,6 +1,6 @@ import createMDX from "@next/mdx"; import remarkGfm from "remark-gfm"; -import { TogglePointInjection } from "@asos/web-toggle-point-webpack/plugins"; +import { TogglePointInjectionPlugin } from "@asos/web-toggle-point-webpack/plugins"; import experimentPointCutConfig from "./src/app/fixtures/experiments/__pointCutConfig.js"; import contentManagementPointCutConfig from "./src/app/fixtures/content-management/__pointCutConfig.js"; import webpackNormalModule from "next/dist/compiled/webpack/NormalModule.js"; @@ -10,7 +10,7 @@ const nextConfig = { pageExtensions: ["js", "md", "mdx", "ts", "tsx"] }; -const togglePointInjection = new TogglePointInjection({ +const togglePointInjection = new TogglePointInjectionPlugin({ pointCuts: [...experimentPointCutConfig, ...contentManagementPointCutConfig], webpackNormalModule }); @@ -18,7 +18,14 @@ const togglePointInjection = new TogglePointInjection({ nextConfig.webpack = (config) => { return { ...config, - plugins: [...config.plugins, togglePointInjection] + plugins: [...config.plugins, togglePointInjection], + resolve: { + ...(config.resolve ?? {}), + alias: { + ...(config.resolve.alias ?? {}), + "react-is": "next/dist/compiled/react-is/cjs/react-is.production.js" + } + } }; }; diff --git a/examples/next/package.json b/examples/next/package.json index 5c9e4c2..667453b 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -1,6 +1,6 @@ { "name": "web-toggle-point-next-example", - "version": "0.4.1", + "version": "0.5.0", "private": true, "type": "module", "scripts": { @@ -8,12 +8,12 @@ "predev": "npm run build-dependencies", "prebuild": "npm run build-dependencies", "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "prelint": "npm run build-dependencies", "lint": "npm run lint:code && npm run lint:docs", "lint:fix": "npm run lint:code -- --fix && npm run lint:docs -- --fix", - "lint:code": "next lint", + "lint:code": "eslint src", "lint:docs": "eslint **/*.mdx" }, "dependencies": { @@ -22,17 +22,19 @@ "@asos/web-toggle-point-webpack": "file:../../packages/webpack", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", - "@next/mdx": "^15.0.3", - "next": "^15.5.6", + "@next/mdx": "^16.1.1", + "next": "^16.1.1", "remark-gfm": "^4.0.0", - "turndown": "^7.2.0" + "turndown": "^7.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3" }, "devDependencies": { - "@next/eslint-plugin-next": "^15.0.3", + "@next/eslint-plugin-next": "^16.1.1", "@types/mdx": "^2.0.13", "@types/turndown": "^5.0.5", "@types/webpack-env": "^1.18.8", - "eslint-config-next": "^15.0.3", + "eslint-config-next": "^16.1.1", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-mdx": "^3.1.5", "path-exists-cli": "^2.0.0" diff --git a/examples/next/src/app/fixtures/content-management/__pointCutConfig.js b/examples/next/src/app/fixtures/content-management/__pointCutConfig.js index bc97cb9..ae45bb3 100644 --- a/examples/next/src/app/fixtures/content-management/__pointCutConfig.js +++ b/examples/next/src/app/fixtures/content-management/__pointCutConfig.js @@ -1,7 +1,8 @@ export default [ { name: "content management", - togglePointModule: "/src/app/fixtures/content-management/withToggledHook", + togglePointModuleSpecifier: + "/src/app/fixtures/content-management/withToggledHook", variantGlobs: [ "./src/app/fixtures/content-management/__variants__/devMode/active/useContentEditable.ts" ] diff --git a/examples/next/src/app/fixtures/experiments/8-toggled-twice/README.mdx b/examples/next/src/app/fixtures/experiments/8-toggled-twice/README.mdx index ecae094..0c4755b 100644 --- a/examples/next/src/app/fixtures/experiments/8-toggled-twice/README.mdx +++ b/examples/next/src/app/fixtures/experiments/8-toggled-twice/README.mdx @@ -20,13 +20,18 @@ It has variants that are colocated in the same folder, with a postfix to their f A bespoke toggle handler unpicks the character ranges from the variant filenames, and adds each letter to the potential variations considered by the join point. -A bespoke reactive toggle point with coupled features store is set to listen for key presses, +A bespoke reactive toggle point with coupled features store is set to listen for key presses, and modify the active feature to match any held alphabetic character, passing a `children` render prop to the varied component that outputs the key pressed. +The supplementary join point has a lazy load strategy applied, ensuring that the extra +variations are code-split into their own chunk. Via the use of `importCodeGeneratorOptions`, +a [webpack magic comment](https://webpack.js.org/api/module-methods/#magic-comments) is set, +ensuring that the chunk is preloaded, and named `"toggled-twice-chunk"`. + ## Activation If an `experiments` header is set, with a `"test-feature": { "bucket": "test-variant" }` -decision, the experimentally varied component will show. If a key press of an alphabetic +decision, the experimentally varied component will show. If a key press of an alphabetic character is held, then this is itself varied, either a "variant 1" or "variant 2" (dependent -on character range), with the key pressed passed as a string child to the component. \ No newline at end of file +on character range), with the key pressed passed as a string child to the component. diff --git a/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/__pointCutConfig.js b/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/__pointCutConfig.js index a7b43a7..87c9fa8 100644 --- a/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/__pointCutConfig.js +++ b/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/__pointCutConfig.js @@ -1,7 +1,17 @@ +import lazyComponentLoadStrategyFactory from "@asos/web-toggle-point-react-pointcuts/lazyComponentLoadStrategyFactory"; + export default { name: "toggled twice experiment", - togglePointModule: import.meta.resolve("./withTogglePoint.tsx"), + togglePointModuleSpecifier: import.meta.resolve("./withTogglePoint.tsx"), variantGlobs: ["./src/app/fixtures/experiments/8-toggled-twice/**/*.?-?.tsx"], joinPointResolver: (path) => path.replace(/.-.\.tsx$/, "tsx"), - toggleHandler: import.meta.resolve("./toggleHandlerFactory.ts") + toggleHandlerFactoryModuleSpecifier: import.meta.resolve( + "./toggleHandlerFactory.ts" + ), + loadStrategy: lazyComponentLoadStrategyFactory({ + importCodeGeneratorOptions: { + webpackMagicComment: + "/* webpackChunkName: 'toggled-twice-chunk', webpackPreload: true */" + } + }) }; diff --git a/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/toggleHandlerFactory.ts b/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/toggleHandlerFactory.ts index 138ada8..2b6399e 100644 --- a/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/toggleHandlerFactory.ts +++ b/examples/next/src/app/fixtures/experiments/8-toggled-twice/__variants__/test-feature/test-variant/toggleHandlerFactory.ts @@ -1,33 +1,43 @@ import React from "react"; import { FEATURE_KEY } from "./constants"; - -type ReactComponentModuleType = { - default: React.Component; -}; -interface ToggleHandler { - togglePoint: ( - joinPoint: ReactComponentModuleType, - featuresMap: Map> - ) => React.Component; - joinPoint: ReactComponentModuleType; - variantPathMap: Map; +type ReactComponentModuleType = { default: () => React.JSX.Element }; +type DynamicReactComponentModuleType = () => Promise; +type LazyComponentType = React.LazyExoticComponent<() => React.JSX.Element>; +interface TogglePointFactory { + togglePoint: ({ + joinPoint, + featuresMap, + unpack + }: { + joinPoint: LazyComponentType; + featuresMap: Map>; + unpack: (value: LazyComponentType) => LazyComponentType; + }) => () => React.JSX.Element; + pack: (value: DynamicReactComponentModuleType) => LazyComponentType; + unpack: (value: LazyComponentType) => LazyComponentType; } -export default ({ togglePoint, joinPoint, variantPathMap }: ToggleHandler) => { - const variantsMap = new Map(); - const featuresMap = new Map([[FEATURE_KEY, variantsMap]]); - - for (const key of variantPathMap.keys()) { - const [, , value] = key.split("."); - const [start, end] = value.split("-"); - - for ( - let charCode = start.charCodeAt(0); - charCode <= end.charCodeAt(0); - charCode++ - ) { - variantsMap.set(String.fromCharCode(charCode), variantPathMap.get(key)!); +interface TogglePoint { + joinPoint: DynamicReactComponentModuleType; + variantPathMap: Map< + string, + () => Promise<{ default: () => React.JSX.Element }> + >; +} +export default ({ togglePoint, pack, unpack }: TogglePointFactory) => + ({ joinPoint, variantPathMap }: TogglePoint) => { + const variantsMap = new Map(); + const featuresMap = new Map([[FEATURE_KEY, variantsMap]]); + for (const key of variantPathMap.keys()) { + const packedValue = pack(variantPathMap.get(key)!); + const [, , value] = key.split("."); + const [start, end] = value.split("-"); + for ( + let charCode = start.charCodeAt(0); + charCode <= end.charCodeAt(0); + charCode++ + ) { + variantsMap.set(String.fromCharCode(charCode), packedValue); + } } - } - - return togglePoint(joinPoint, featuresMap); -}; + return togglePoint({ joinPoint: pack(joinPoint), featuresMap, unpack }); + }; diff --git a/examples/next/src/app/fixtures/experiments/__pointCutConfig.js b/examples/next/src/app/fixtures/experiments/__pointCutConfig.js index b3c0288..31b8cc7 100644 --- a/examples/next/src/app/fixtures/experiments/__pointCutConfig.js +++ b/examples/next/src/app/fixtures/experiments/__pointCutConfig.js @@ -3,7 +3,7 @@ import toggledTwicePointCutConfig from "./8-toggled-twice/__variants__/test-feat export default [ { name: "experiments", - togglePointModule: "/src/app/fixtures/experiments/withTogglePoint", + togglePointModuleSpecifier: "/src/app/fixtures/experiments/withTogglePoint", variantGlobs: [ "./src/app/fixtures/experiments/**/__variants__/*/*/!(*.spec).tsx" ] diff --git a/examples/next/tsconfig.json b/examples/next/tsconfig.json index 9386c9a..f6b415e 100644 --- a/examples/next/tsconfig.json +++ b/examples/next/tsconfig.json @@ -22,7 +22,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "plugins": [ { "name": "next" @@ -34,7 +34,8 @@ "next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", - "**/*.tsx" + "**/*.tsx", + ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" diff --git a/examples/serve/README.md b/examples/serve/README.md index 2d17c37..3a882bc 100644 --- a/examples/serve/README.md +++ b/examples/serve/README.md @@ -4,6 +4,8 @@ This example shows the use of [`webpack`](../../packages/webpack/docs/README.md) and [`features`](../../packages/features/docs/README.md) packages, as part of a simple [serve](https://github.com/vercel/serve) fully client-rendered application. +It uses a [`module` output](https://webpack.js.org/configuration/output/#outputmodule) with [es2022](https://262.ecma-international.org/13.0/) [target](https://webpack.js.org/configuration/target/). + It uses a `globalFeaturesStoreFactory` from the `features` package, to hold a invariant global toggle state. It demonstrates a setup that utilises the `toggleHandler`, `variantGlobs`, and `controlResolver` options of the Webpack plugin, with some basic convention-based filesystem approaches to toggling: @@ -15,6 +17,7 @@ It demonstrates a setup that utilises the `toggleHandler`, `variantGlobs`, and ` - `/src/fixtures/translation/languages/pt-BR/translations.json` (variant) - This uses a `joinPointGlob` setting that points to a single file, rather than attempting to match in sub-directories. - This uses a bespoke toggle handler to match the language to the path. + - This uses the `staticLoadStrategyFactory` from the `webpack` package, meaning all variant modules are imported at application bootstrap. 2. selecting a site-specific method, based on a site prefix of the url. - This uses modules stored at: - `/src/fixtures/config/somethingSiteSpecific.js` (default) @@ -31,6 +34,7 @@ It demonstrates a setup that utilises the `toggleHandler`, `variantGlobs`, and ` - `/src/fixtures/audience/cohort-2/bespoke-experience.js` (variant) - This uses a bespoke `controlResolver` which matches an alternate file name for the default to the variants. - This uses a bespoke toggle handler which has no parent folder for variant folders, which are matched using a naming convention in the glob. + - This uses a `deferredDynamicImportLoadStrategyFactory` from the `webpack` package, producing lazy-loaded chunks for variant code. 4. selecting a theme-specific method, based on a date - This uses modules stored at: - `/src/fixtures/event/theme.css` (default) diff --git a/examples/serve/dist/.gitignore b/examples/serve/dist/.gitignore index a9b203a..79df428 100644 --- a/examples/serve/dist/.gitignore +++ b/examples/serve/dist/.gitignore @@ -1 +1,3 @@ -main.js +*.js +*.map +*.txt diff --git a/examples/serve/docs/CHANGELOG.md b/examples/serve/docs/CHANGELOG.md index ecb3b6c..1e59cdc 100644 --- a/examples/serve/docs/CHANGELOG.md +++ b/examples/serve/docs/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - ????-??-?? + +### Changed + +- moved to `production` webpack mode with `source-map` devtool and `source-map-loader`, for clarity when using dev tools +- updates for new `webpack` package: + - moving to `toggleHandlerFactories` from `toggleHandlers` + - using non-default `loadStrategy` for two of the four examples + - `audience` becomes `deferredImport` (`async`) + - `translation` becomes `static` (which was the previous default...) + - other default to `deferredRequire` (synchronous, but delayed require of module) +- move to use `import.meta.resolve` replacing the hand-rolled `getToggleHandlerPath.js` +- move `toggleHandlers` to `toggleHandlerFactories`, to align with updated `webpack` package +- update output format to `es2022` and `module`, to validate loading strategies against this + ## [0.4.0] - 2025-10-21 ### Added diff --git a/examples/serve/package.json b/examples/serve/package.json index f8428c0..ca1859d 100644 --- a/examples/serve/package.json +++ b/examples/serve/package.json @@ -1,6 +1,6 @@ { "name": "web-toggle-point-serve-example", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "private": true, "scripts": { @@ -23,7 +23,7 @@ "css-loader": "^7.1.2", "path-exists-cli": "^2.0.0", "style-loader": "^4.0.0", - "webpack": "^5.38.1", + "webpack": "^5.99.7", "webpack-cli": "^4.7.2", "webpack-node-externals": "^3.0.0" }, diff --git a/examples/serve/src/fixtures/audience/__pointCutConfig.js b/examples/serve/src/fixtures/audience/__pointCutConfig.js index b0d5d34..b420d1d 100644 --- a/examples/serve/src/fixtures/audience/__pointCutConfig.js +++ b/examples/serve/src/fixtures/audience/__pointCutConfig.js @@ -1,11 +1,15 @@ import { basename, posix } from "path"; -import getToggleHandlerPath from "../../getToggleHandlerPath.js"; +// eslint-disable-next-line import/no-unresolved -- https://github.com/import-js/eslint-plugin-import/issues/1810 +import loadStrategyFactory from "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory"; export default { name: "audience", - togglePointModule: "/src/fixtures/audience/__togglePoint.js", + togglePointModuleSpecifier: "/src/fixtures/audience/__togglePoint.js", variantGlobs: ["./src/fixtures/audience/**/cohort-[1-9]*([0-9])/*.js"], - toggleHandler: getToggleHandlerPath("singlePathSegment.js"), + toggleHandlerFactoryModuleSpecifier: import.meta.resolve( + "../../toggleHandlerFactories/singlePathSegment.js" + ), joinPointResolver: (path) => - posix.resolve(path, "../..", basename(path).replace("bespoke", "control")) + posix.resolve(path, "../..", basename(path).replace("bespoke", "control")), + loadStrategy: loadStrategyFactory() }; diff --git a/examples/serve/src/fixtures/audience/__togglePoint.js b/examples/serve/src/fixtures/audience/__togglePoint.js index cce7a81..a40ff32 100644 --- a/examples/serve/src/fixtures/audience/__togglePoint.js +++ b/examples/serve/src/fixtures/audience/__togglePoint.js @@ -1,9 +1,9 @@ import featuresStore from "./__featuresStore.js"; -export default (joinPoint, featuresMap) => { +export default async ({ joinPoint, featuresMap, unpack }) => { const audience = featuresStore.getFeatures(); if (audience && featuresMap.has(audience)) { - return featuresMap.get(audience).default; + return (await unpack(featuresMap.get(audience))).default(); } - return joinPoint.default; + return (await unpack(joinPoint)).default(); }; diff --git a/examples/serve/src/fixtures/config/__pointCutConfig.js b/examples/serve/src/fixtures/config/__pointCutConfig.js index ca7293a..0b7a5d4 100644 --- a/examples/serve/src/fixtures/config/__pointCutConfig.js +++ b/examples/serve/src/fixtures/config/__pointCutConfig.js @@ -1,10 +1,11 @@ -import getToggleHandlerPath from "../../getToggleHandlerPath.js"; import joinPointResolver from "../../joinPointResolver.js"; export default { name: "configuration", - togglePointModule: "/src/fixtures/config/__togglePoint.js", + togglePointModuleSpecifier: "/src/fixtures/config/__togglePoint.js", variantGlobs: ["./src/fixtures/config/**/sites/*/*.js"], - toggleHandler: getToggleHandlerPath("listExtractionFromPathSegment.js"), + toggleHandlerFactoryModuleSpecifier: import.meta.resolve( + "../../toggleHandlerFactories/listExtractionFromPathSegment.js" + ), joinPointResolver }; diff --git a/examples/serve/src/fixtures/config/__togglePoint.js b/examples/serve/src/fixtures/config/__togglePoint.js index e63afe7..af461be 100644 --- a/examples/serve/src/fixtures/config/__togglePoint.js +++ b/examples/serve/src/fixtures/config/__togglePoint.js @@ -1,9 +1,9 @@ import featuresStore from "./__featuresStore.js"; -export default (joinPoint, featuresMap) => { +export default ({ joinPoint, featuresMap, unpack }) => { const site = featuresStore.getFeatures(); if (featuresMap.has(site)) { - return featuresMap.get(site).default; + return unpack(featuresMap.get(site)).default; } - return joinPoint.default; + return unpack(joinPoint).default; }; diff --git a/examples/serve/src/fixtures/event/__pointCutConfig.js b/examples/serve/src/fixtures/event/__pointCutConfig.js index b5c3ca6..958a2c7 100644 --- a/examples/serve/src/fixtures/event/__pointCutConfig.js +++ b/examples/serve/src/fixtures/event/__pointCutConfig.js @@ -1,9 +1,9 @@ -import getToggleHandlerPath from "../../getToggleHandlerPath.js"; - export default { name: "event", - togglePointModule: "/src/fixtures/event/__togglePoint.js", + togglePointModuleSpecifier: "/src/fixtures/event/__togglePoint.js", variantGlobs: ["./src/fixtures/event/**/*.*.css"], - toggleHandler: getToggleHandlerPath("singleFilenameDottedSegment.js"), + toggleHandlerFactoryModuleSpecifier: import.meta.resolve( + "../../toggleHandlerFactories/singleFilenameDottedSegment.js" + ), joinPointResolver: (path) => path.replace(/\.([^.]+)\.css$/, ".css") }; diff --git a/examples/serve/src/fixtures/event/__togglePoint.js b/examples/serve/src/fixtures/event/__togglePoint.js index c837238..35a3b66 100644 --- a/examples/serve/src/fixtures/event/__togglePoint.js +++ b/examples/serve/src/fixtures/event/__togglePoint.js @@ -1,9 +1,9 @@ import featuresStore from "./__featuresStore.js"; -export default (joinPoint, featuresMap) => { +export default ({ joinPoint, featuresMap, unpack }) => { const event = featuresStore.getFeatures(); if (featuresMap.has(event)) { - return featuresMap.get(event).default; + return unpack(featuresMap.get(event)).default; } - return joinPoint; + return unpack(joinPoint).default; }; diff --git a/examples/serve/src/fixtures/translation/__pointCutConfig.js b/examples/serve/src/fixtures/translation/__pointCutConfig.js index 99cee63..0ff6642 100644 --- a/examples/serve/src/fixtures/translation/__pointCutConfig.js +++ b/examples/serve/src/fixtures/translation/__pointCutConfig.js @@ -1,8 +1,11 @@ import joinPointResolver from "../../joinPointResolver.js"; +// eslint-disable-next-line import/no-unresolved -- https://github.com/import-js/eslint-plugin-import/issues/1810 +import loadStrategyFactory from "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/staticLoadStrategyFactory"; export default { name: "translation", - togglePointModule: "/src/fixtures/translation/__togglePoint.js", + togglePointModuleSpecifier: "/src/fixtures/translation/__togglePoint.js", variantGlobs: ["./src/fixtures/translation/languages/*/*.json"], - joinPointResolver + joinPointResolver, + loadStrategy: loadStrategyFactory() }; diff --git a/examples/serve/src/fixtures/translation/__togglePoint.js b/examples/serve/src/fixtures/translation/__togglePoint.js index 5b13eb8..5a2c9ed 100644 --- a/examples/serve/src/fixtures/translation/__togglePoint.js +++ b/examples/serve/src/fixtures/translation/__togglePoint.js @@ -1,9 +1,9 @@ import featuresStore from "./__featuresStore.js"; -export default (joinPoint, featuresMap) => { +export default ({ joinPoint, featuresMap, unpack }) => { const language = featuresStore.getFeatures(); if (featuresMap.has(language)) { - return featuresMap.get(language); + return unpack(featuresMap.get(language)).default; } - return joinPoint; + return unpack(joinPoint).default; }; diff --git a/examples/serve/src/getToggleHandlerPath.js b/examples/serve/src/getToggleHandlerPath.js deleted file mode 100644 index 2c652b0..0000000 --- a/examples/serve/src/getToggleHandlerPath.js +++ /dev/null @@ -1,6 +0,0 @@ -import { posix } from "path"; - -const getToggleHandlerPath = (...paths) => - posix.join("/", "src", "toggleHandlers", ...paths); - -export default getToggleHandlerPath; diff --git a/examples/serve/src/index.js b/examples/serve/src/index.js index c3e4bb1..98976b9 100644 --- a/examples/serve/src/index.js +++ b/examples/serve/src/index.js @@ -14,5 +14,5 @@ import styles from "./fixtures/event/theme.css"; appendText("Some translated content: " + translations.message); appendText("Some site-specific content: " + siteSpecific1()); appendText("Some more site-specific content: " + siteSpecific2()); -appendText("Some audience-specific content: " + audienceSpecific()); +appendText("Some audience-specific content: " + (await audienceSpecific)); appendText("Some event-themed content", styles.theme); diff --git a/examples/serve/src/toggleHandlerFactories/listExtractionFromPathSegment.js b/examples/serve/src/toggleHandlerFactories/listExtractionFromPathSegment.js new file mode 100644 index 0000000..2000c2b --- /dev/null +++ b/examples/serve/src/toggleHandlerFactories/listExtractionFromPathSegment.js @@ -0,0 +1,12 @@ +export default ({ togglePoint, pack, unpack }) => + ({ joinPoint, variantPathMap }) => { + const featuresMap = variantPathMap.keys().reduce((map, key) => { + const [, , value] = key.split("/"); + const list = value.split(","); + for (const value of list) { + map.set(value, pack(variantPathMap.get(key))); + } + return map; + }, new Map()); + return togglePoint({ joinPoint: pack(joinPoint), featuresMap, unpack }); + }; diff --git a/examples/serve/src/toggleHandlerFactories/singleFilenameDottedSegment.js b/examples/serve/src/toggleHandlerFactories/singleFilenameDottedSegment.js new file mode 100644 index 0000000..fce79f2 --- /dev/null +++ b/examples/serve/src/toggleHandlerFactories/singleFilenameDottedSegment.js @@ -0,0 +1,9 @@ +export default ({ togglePoint, pack, unpack }) => + ({ joinPoint, variantPathMap }) => { + const featuresMap = new Map(); + for (const key of variantPathMap.keys()) { + const [, , value] = key.split("."); + featuresMap.set(value, pack(variantPathMap.get(key))); + } + return togglePoint({ joinPoint: pack(joinPoint), featuresMap, unpack }); + }; diff --git a/examples/serve/src/toggleHandlerFactories/singlePathSegment.js b/examples/serve/src/toggleHandlerFactories/singlePathSegment.js new file mode 100644 index 0000000..dc5bc80 --- /dev/null +++ b/examples/serve/src/toggleHandlerFactories/singlePathSegment.js @@ -0,0 +1,9 @@ +export default ({ togglePoint, pack, unpack }) => + ({ joinPoint, variantPathMap }) => { + const featuresMap = new Map(); + for (const key of variantPathMap.keys()) { + const [, value] = key.split("/"); + featuresMap.set(value, pack(variantPathMap.get(key))); + } + return togglePoint({ joinPoint: pack(joinPoint), featuresMap, unpack }); + }; diff --git a/examples/serve/src/toggleHandlers/listExtractionFromPathSegment.js b/examples/serve/src/toggleHandlers/listExtractionFromPathSegment.js deleted file mode 100644 index e8189b7..0000000 --- a/examples/serve/src/toggleHandlers/listExtractionFromPathSegment.js +++ /dev/null @@ -1,11 +0,0 @@ -export default ({ togglePoint, joinPoint, variantPathMap }) => { - const featuresMap = variantPathMap.keys().reduce((map, key) => { - const [, , value] = key.split("/"); - const list = value.split(","); - for (const value of list) { - map.set(value, variantPathMap.get(key)); - } - return map; - }, new Map()); - return togglePoint(joinPoint, featuresMap); -}; diff --git a/examples/serve/src/toggleHandlers/singleFilenameDottedSegment.js b/examples/serve/src/toggleHandlers/singleFilenameDottedSegment.js deleted file mode 100644 index 2d9a6a3..0000000 --- a/examples/serve/src/toggleHandlers/singleFilenameDottedSegment.js +++ /dev/null @@ -1,8 +0,0 @@ -export default ({ togglePoint, joinPoint, variantPathMap }) => { - const featuresMap = new Map(); - for (const key of variantPathMap.keys()) { - const [, , value] = key.split("."); - featuresMap.set(value, variantPathMap.get(key)); - } - return togglePoint(joinPoint, featuresMap); -}; diff --git a/examples/serve/src/toggleHandlers/singlePathSegment.js b/examples/serve/src/toggleHandlers/singlePathSegment.js deleted file mode 100644 index 7a72925..0000000 --- a/examples/serve/src/toggleHandlers/singlePathSegment.js +++ /dev/null @@ -1,8 +0,0 @@ -export default ({ togglePoint, joinPoint, variantPathMap }) => { - const featuresMap = new Map(); - for (const key of variantPathMap.keys()) { - const [, value] = key.split("/"); - featuresMap.set(value, variantPathMap.get(key)); - } - return togglePoint(joinPoint, featuresMap); -}; diff --git a/examples/serve/webpack.config.js b/examples/serve/webpack.config.js index ffe3c76..d866731 100644 --- a/examples/serve/webpack.config.js +++ b/examples/serve/webpack.config.js @@ -4,19 +4,25 @@ import audience from "./src/fixtures/audience/__pointCutConfig.js"; import config from "./src/fixtures/config/__pointCutConfig.js"; import translation from "./src/fixtures/translation/__pointCutConfig.js"; import event from "./src/fixtures/event/__pointCutConfig.js"; -import { TogglePointInjection } from "@asos/web-toggle-point-webpack/plugins"; +import { TogglePointInjectionPlugin } from "@asos/web-toggle-point-webpack/plugins"; import { fileURLToPath } from "url"; export default { entry: "./src/index.js", - mode: "development", + mode: "production", + devtool: "source-map", output: { + module: true, filename: "main.js", path: resolve(dirname(fileURLToPath(import.meta.url)), "dist") }, + experiments: { + outputModule: true + }, + target: "es2022", externals: [externals()], plugins: [ - new TogglePointInjection({ + new TogglePointInjectionPlugin({ pointCuts: [audience, config, translation, event] }) ], @@ -35,6 +41,11 @@ export default { } } ] + }, + { + test: /\.js$/, + enforce: "pre", + use: ["source-map-loader"] } ] } diff --git a/package-lock.json b/package-lock.json index 02810f4..459d77d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@asos/web-toggle-point", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@asos/web-toggle-point", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "workspaces": [ "packages/features", @@ -27,7 +27,6 @@ "@eslint/js": "^9.15.0", "@eslint/markdown": "^6.2.1", "@rollup/plugin-babel": "^6.0.2", - "@rollup/plugin-commonjs": "^25.0.4", "@rollup/plugin-node-resolve": "^15.2.1", "@rollup/plugin-replace": "^5.0.1", "@rollup/plugin-terser": "^0.4.3", @@ -47,17 +46,15 @@ "jsdoc": "^4.0.4", "lint-staged": "^15.2.10", "prettier": "^3.4.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", "rollup": "^3.29.2" }, "engines": { - "node": ">=20" + "node": ">=20.8" } }, "examples/express": { "name": "web-toggle-point-express-example", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@asos/web-toggle-point-features": "file:../../packages/features", "@asos/web-toggle-point-react-pointcuts": "file:../../packages/react-pointcuts", @@ -67,9 +64,11 @@ "cross-env": "^7.0.3", "express": "^4.17.1", "http-status-codes": "^2.3.0", - "react": ">=17", - "react-dom": ">=17", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-is": "^18.3.1", "react-redux": "^9.2.0", + "source-map-loader": "^5.0.0", "valtio": "^2.1.5" }, "devDependencies": { @@ -82,7 +81,7 @@ "source-map-loader": "^5.0.0", "style-loader": "^4.0.0", "ts-loader": "^9.5.2", - "webpack": "^5.38.1", + "webpack": "^5.99.7", "webpack-cli": "^4.7.2", "webpack-node-externals": "^3.0.0" }, @@ -93,26 +92,32 @@ "@playwright/test": "^1.56.0" } }, + "examples/express/node_modules/react-is": { + "version": "18.3.1", + "license": "MIT" + }, "examples/next": { "name": "web-toggle-point-next-example", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@asos/web-toggle-point-features": "file:../../packages/features", "@asos/web-toggle-point-react-pointcuts": "file:../../packages/react-pointcuts", "@asos/web-toggle-point-webpack": "file:../../packages/webpack", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", - "@next/mdx": "^15.0.3", - "next": "^15.5.6", + "@next/mdx": "^16.1.1", + "next": "^16.1.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", "remark-gfm": "^4.0.0", "turndown": "^7.2.0" }, "devDependencies": { - "@next/eslint-plugin-next": "^15.0.3", + "@next/eslint-plugin-next": "^16.1.1", "@types/mdx": "^2.0.13", "@types/turndown": "^5.0.5", "@types/webpack-env": "^1.18.8", - "eslint-config-next": "^15.0.3", + "eslint-config-next": "^16.1.1", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-mdx": "^3.1.5", "path-exists-cli": "^2.0.0" @@ -121,9 +126,36 @@ "@playwright/test": "^1.56.0" } }, + "examples/next/node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "examples/next/node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "examples/next/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "examples/serve": { "name": "web-toggle-point-serve-example", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "@asos/web-toggle-point-features": "file:../../packages/features", "@asos/web-toggle-point-webpack": "file:../../packages/webpack", @@ -133,7 +165,7 @@ "css-loader": "^7.1.2", "path-exists-cli": "^2.0.0", "style-loader": "^4.0.0", - "webpack": "^5.38.1", + "webpack": "^5.99.7", "webpack-cli": "^4.7.2", "webpack-node-externals": "^3.0.0" }, @@ -2054,40 +2086,6 @@ "node": ">=18" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "dev": true, @@ -3490,11 +3488,15 @@ "license": "BSD-2-Clause" }, "node_modules/@next/env": { - "version": "15.5.6", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", + "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.5.6", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.1.tgz", + "integrity": "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==", "dev": true, "license": "MIT", "dependencies": { @@ -3503,6 +3505,8 @@ }, "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", "dev": true, "license": "MIT", "dependencies": { @@ -3517,7 +3521,9 @@ } }, "node_modules/@next/mdx": { - "version": "15.5.6", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.1.1.tgz", + "integrity": "sha512-XvlZ28/K7kXb1vgTeZWHjjfxDx9BVz/s1bbVlsFOvPfYuSVRmlUkhaiyJTA/7mm9OdpeC57+uHR6k1fUcn5AaA==", "license": "MIT", "dependencies": { "source-map": "^0.7.0" @@ -3542,8 +3548,26 @@ "node": ">= 12" } }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz", + "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.6", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz", + "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==", "cpu": [ "x64" ], @@ -3556,6 +3580,102 @@ "node": ">= 10" } }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz", + "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz", + "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz", + "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz", + "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz", + "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz", + "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "dev": true, @@ -4317,11 +4437,6 @@ "version": "1.1.0", "license": "MIT" }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.14.0", - "dev": true, - "license": "MIT" - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "devOptional": true, @@ -4578,23 +4693,6 @@ "@types/estree": "*" } }, - "node_modules/@types/fs-extra": { - "version": "8.1.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/glob": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "devOptional": true, @@ -4684,11 +4782,6 @@ "version": "2.0.13", "license": "MIT" }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "dev": true, - "license": "MIT" - }, "node_modules/@types/minimist": { "version": "1.2.5", "dev": true, @@ -4771,16 +4864,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", + "integrity": "sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==", "devOptional": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/type-utils": "8.46.2", - "@typescript-eslint/utils": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/type-utils": "8.50.1", + "@typescript-eslint/utils": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -4793,7 +4887,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.2", + "@typescript-eslint/parser": "^8.50.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -4807,14 +4901,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", + "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4" }, "engines": { @@ -4830,11 +4926,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.1.tgz", + "integrity": "sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.2", - "@typescript-eslint/types": "^8.46.2", + "@typescript-eslint/tsconfig-utils": "^8.50.1", + "@typescript-eslint/types": "^8.50.1", "debug": "^4.3.4" }, "engines": { @@ -4849,11 +4947,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.1.tgz", + "integrity": "sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2" + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4864,7 +4964,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.1.tgz", + "integrity": "sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4878,13 +4980,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.1.tgz", + "integrity": "sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==", "devOptional": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2", - "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/utils": "8.50.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -4901,7 +5005,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.1.tgz", + "integrity": "sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4912,18 +5018,19 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.1.tgz", + "integrity": "sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.2", - "@typescript-eslint/tsconfig-utils": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/visitor-keys": "8.46.2", + "@typescript-eslint/project-service": "8.50.1", + "@typescript-eslint/tsconfig-utils": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/visitor-keys": "8.50.1", "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", + "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -4939,6 +5046,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4946,6 +5055,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -4959,6 +5070,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4968,13 +5081,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.1.tgz", + "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.2", - "@typescript-eslint/types": "8.46.2", - "@typescript-eslint/typescript-estree": "8.46.2" + "@typescript-eslint/scope-manager": "8.50.1", + "@typescript-eslint/types": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4989,10 +5104,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.2", + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.1.tgz", + "integrity": "sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/types": "8.50.1", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -5005,6 +5122,8 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5017,10 +5136,10 @@ "version": "1.3.0", "license": "ISC" }, - "node_modules/@unrs/resolver-binding-darwin-x64": { + "node_modules/@unrs/resolver-binding-darwin-arm64": { "version": "1.11.1", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -5527,14 +5646,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.findlast": { "version": "1.2.5", "license": "MIT", @@ -5810,6 +5921,18 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-transform-import-meta-x": { + "version": "0.0.3", + "dev": true, + "license": "BSD", + "dependencies": { + "@babel/template": "^7.4.4", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@babel/core": "^7.10.0" + } + }, "node_modules/babel-plugin-transform-react-remove-prop-types": { "version": "0.4.24", "license": "MIT" @@ -5871,7 +5994,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -6058,7 +6183,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -6075,11 +6202,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -6210,7 +6337,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", "funding": [ { "type": "opencollective", @@ -7441,17 +7570,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/doctrine": { "version": "2.1.0", "license": "Apache-2.0", @@ -7563,7 +7681,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.237", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "license": "ISC" }, "node_modules/emittery": { @@ -7768,7 +7888,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "devOptional": true, "license": "MIT" }, @@ -7899,8 +8021,6 @@ }, "node_modules/eslint": { "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", @@ -7961,23 +8081,24 @@ "link": true }, "node_modules/eslint-config-next": { - "version": "15.5.6", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.1.tgz", + "integrity": "sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.5.6", - "@rushstack/eslint-patch": "^1.10.3", - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@next/eslint-plugin-next": "16.1.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.31.0", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", - "eslint-plugin-react-hooks": "^5.0.0" + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" }, "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "peerDependenciesMeta": { @@ -7986,6 +8107,39 @@ } } }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-config-prettier": { "version": "9.1.2", "license": "MIT", @@ -9081,7 +9235,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -9266,19 +9419,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs-monkey": { "version": "1.1.0", "dev": true, @@ -9542,32 +9682,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "10.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/glob": "^7.1.1", - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.0.3", - "glob": "^7.1.3", - "ignore": "^5.1.1", - "merge2": "^1.2.3", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/globby/node_modules/slash": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/gopd": { "version": "1.2.0", "license": "MIT", @@ -9583,11 +9697,6 @@ "devOptional": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "devOptional": true, - "license": "MIT" - }, "node_modules/hard-rejection": { "version": "2.1.0", "dev": true, @@ -9731,6 +9840,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "dev": true, @@ -10381,14 +10507,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-plain-object": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-port-reachable": { "version": "4.0.0", "license": "MIT", @@ -13233,14 +13351,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonpointer": { "version": "5.0.1", "dev": true, @@ -15071,11 +15181,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.6", + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", + "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", "license": "MIT", "dependencies": { - "@next/env": "15.5.6", + "@next/env": "16.1.1", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -15084,18 +15197,18 @@ "next": "dist/bin/next" }, "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.6", - "@next/swc-darwin-x64": "15.5.6", - "@next/swc-linux-arm64-gnu": "15.5.6", - "@next/swc-linux-arm64-musl": "15.5.6", - "@next/swc-linux-x64-gnu": "15.5.6", - "@next/swc-linux-x64-musl": "15.5.6", - "@next/swc-win32-arm64-msvc": "15.5.6", - "@next/swc-win32-x64-msvc": "15.5.6", - "sharp": "^0.34.3" + "@next/swc-darwin-arm64": "16.1.1", + "@next/swc-darwin-x64": "16.1.1", + "@next/swc-linux-arm64-gnu": "16.1.1", + "@next/swc-linux-arm64-musl": "16.1.1", + "@next/swc-linux-x64-gnu": "16.1.1", + "@next/swc-linux-x64-musl": "16.1.1", + "@next/swc-win32-arm64-msvc": "16.1.1", + "@next/swc-win32-x64-msvc": "16.1.1", + "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -15122,6 +15235,8 @@ }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", @@ -15181,7 +15296,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.26", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, "node_modules/nopt": { @@ -15770,14 +15887,6 @@ "version": "0.1.12", "license": "MIT" }, - "node_modules/path-type": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/pathe": { "version": "2.0.3", "dev": true, @@ -15789,7 +15898,6 @@ }, "node_modules/picomatch": { "version": "4.0.3", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -17176,26 +17284,6 @@ "semver": "bin/semver" } }, - "node_modules/rollup-plugin-copy": { - "version": "3.5.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/fs-extra": "^8.0.1", - "colorette": "^1.1.0", - "fs-extra": "^8.1.0", - "globby": "10.0.1", - "is-plain-object": "^3.0.0" - }, - "engines": { - "node": ">=8.3" - } - }, - "node_modules/rollup-plugin-copy/node_modules/colorette": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "funding": [ @@ -18530,7 +18618,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "devOptional": true, "license": "MIT", "dependencies": { @@ -18597,7 +18687,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -18926,6 +19015,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.50.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.50.1.tgz", + "integrity": "sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@typescript-eslint/typescript-estree": "8.50.1", + "@typescript-eslint/utils": "8.50.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "dev": true, @@ -19264,14 +19377,6 @@ "dev": true, "license": "ISC" }, - "node_modules/universalify": { - "version": "0.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "license": "MIT", @@ -19313,7 +19418,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -19630,7 +19737,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.102.1", + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", + "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -19642,21 +19751,21 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.17.4", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", + "terser-webpack-plugin": "^5.3.16", "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, @@ -20250,6 +20359,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zwitch": { "version": "2.0.4", "license": "MIT", @@ -20260,7 +20392,7 @@ }, "packages/features": { "name": "@asos/web-toggle-point-features", - "version": "0.5.0", + "version": "0.5.2", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0" @@ -20310,9 +20442,10 @@ }, "packages/react-pointcuts": { "name": "@asos/web-toggle-point-react-pointcuts", - "version": "0.5.0", + "version": "0.5.2", "license": "MIT", "dependencies": { + "@asos/web-toggle-point-webpack": "file:../webpack", "@babel/runtime": "^7.26.0" }, "devDependencies": { @@ -20324,6 +20457,7 @@ "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^16.0.1", "@types/webpack-env": "^1.18.0", + "babel-plugin-transform-import-meta-x": "^0.0.3", "eslint-plugin-jsdoc": "^50.5.0", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.31.10", @@ -20334,6 +20468,7 @@ "jest-extended": "^4.0.2", "jsdoc": "^4.0.4", "react": "^18.3.1", + "react-is": "^18.3.1", "rimraf": "^6.0.1", "rollup-plugin-auto-external": "^2.0.0", "shx": "^0.4.0", @@ -20341,14 +20476,19 @@ }, "peerDependencies": { "@asos/web-toggle-point-features": "file:../features", - "@asos/web-toggle-point-webpack": "file:../webpack", "prop-types": "^15.7.2", - "react": ">=17" + "react": ">=17", + "react-is": ">=17" } }, + "packages/react-pointcuts/node_modules/react-is": { + "version": "18.3.1", + "dev": true, + "license": "MIT" + }, "packages/ssr": { "name": "@asos/web-toggle-point-ssr", - "version": "0.2.6", + "version": "0.2.7", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", @@ -20386,7 +20526,7 @@ }, "packages/webpack": { "name": "@asos/web-toggle-point-webpack", - "version": "0.9.0", + "version": "0.9.2", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", @@ -20398,6 +20538,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.1", "@types/webpack-env": "^1.18.0", + "babel-plugin-transform-import-meta-x": "^0.0.3", "eslint-plugin-jsdoc": "^50.5.0", "jest": "^29.7.0", "jsdoc": "^4.0.2", @@ -20405,16 +20546,18 @@ "memfs": "^4.15.0", "rimraf": "^6.0.1", "rollup-plugin-auto-external": "^2.0.0", - "rollup-plugin-copy": "^3.5.0", "schema-utils": "^4.2.0", "shx": "^0.4.0", "transform-markdown-links": "^2.1.0", - "webpack": "^5.88.2", + "webpack": "^5.104.1", "webpack-cli": "^4.10.0", "webpack-test-utils": "^2.1.0" }, + "engines": { + "node": ">=20.11.0" + }, "peerDependencies": { - "webpack": ">=5.70" + "webpack": ">=5.104.1" } }, "peripheral/babel-preset-asos": { @@ -20480,7 +20623,7 @@ }, "test/automation": { "name": "web-toggle-point-automation-tests", - "version": "0.1.4", + "version": "0.1.5", "devDependencies": { "@playwright/test": "^1.56.0", "@types/node": "^22.9.1", diff --git a/package.json b/package.json index 52829d1..2e1d5ab 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "@asos/web-toggle-point", - "version": "0.13.1", + "version": "0.14.0", "repository": "git@github.com:asos/web-toggle-point.git", "homepage": "https://asos.github.io/web-toggle-point/", "license": "MIT", "private": true, "engines": { - "node": ">=20" + "node": ">=20.8" }, "scripts": { "build": "npm run build --workspace packages", @@ -20,6 +20,7 @@ "lint:fix": "npm run lint:fix --workspaces --if-present && npm run lint:docs -- --fix && npm run lint:danger -- --fix", "test": "npm run test:unit && npm run test:automation", "test:unit": "npm test --workspace packages", + "test:unit:coverage": "npm test --workspace packages -- --coverage", "test:automation": "npm test --workspace test/automation" }, "devDependencies": { @@ -32,7 +33,6 @@ "@eslint/js": "^9.15.0", "@eslint/markdown": "^6.2.1", "@rollup/plugin-babel": "^6.0.2", - "@rollup/plugin-commonjs": "^25.0.4", "@rollup/plugin-node-resolve": "^15.2.1", "@rollup/plugin-replace": "^5.0.1", "@rollup/plugin-terser": "^0.4.3", @@ -52,8 +52,6 @@ "jsdoc": "^4.0.4", "lint-staged": "^15.2.10", "prettier": "^3.4.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", "rollup": "^3.29.2" }, "workspaces": [ diff --git a/packages/features/docs/CHANGELOG.md b/packages/features/docs/CHANGELOG.md index b0efa1d..7375f36 100644 --- a/packages/features/docs/CHANGELOG.md +++ b/packages/features/docs/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.2] - ????-??-?? + +### Changed + +- added some missing test coverage for the `ssrBackedReactContext` store + ## [0.5.1] - 2025-11-14 ### Fixed diff --git a/packages/features/docs/README.md b/packages/features/docs/README.md index b53cb74..d985705 100644 --- a/packages/features/docs/README.md +++ b/packages/features/docs/README.md @@ -79,7 +79,7 @@ It exports a store with: import express from "express"; const app = express(); - const featuresStore = requestScopedFeaturesStoreFactory({ toggleType: "some type of toggle" }); + const featuresStore = nodeRequestScopedFeaturesStoreFactory({ toggleType: "some type of toggle" }); app.use((request, response, next) => { const value = ?? // some value holding toggle state, either based on `request`, or scoped from outside this middleware, etc. diff --git a/packages/features/package.json b/packages/features/package.json index 087fb41..d2a25d3 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -1,7 +1,7 @@ { "name": "@asos/web-toggle-point-features", "description": "toggle point features code, used to store toggle state", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "type": "module", "main": "./lib/global.js", diff --git a/packages/react-pointcuts/babel.jest.json b/packages/react-pointcuts/babel.jest.json new file mode 100644 index 0000000..984bae4 --- /dev/null +++ b/packages/react-pointcuts/babel.jest.json @@ -0,0 +1,12 @@ +{ + "plugins": [ + [ + "babel-plugin-transform-import-meta-x", + { + "replacements": { + "filename": "__filename" + } + } + ] + ] +} diff --git a/packages/react-pointcuts/build/rollup.mjs b/packages/react-pointcuts/build/rollup.mjs index 4ac969c..2fca522 100644 --- a/packages/react-pointcuts/build/rollup.mjs +++ b/packages/react-pointcuts/build/rollup.mjs @@ -1,46 +1,66 @@ -import pkg from "../package.json" with { type: "json" }; import babel from "@rollup/plugin-babel"; import resolve from "@rollup/plugin-node-resolve"; import external from "rollup-plugin-auto-external"; import commonjs from "@rollup/plugin-commonjs"; import terser from "@rollup/plugin-terser"; import keepExternalComments from "./keepExternalComments.mjs"; +import replace from "@rollup/plugin-replace"; -export default ({ config_isClient }) => { - const CLIENT = JSON.parse(config_isClient); - const [esOutputFile, cjsOutputFile, extraPlugins] = { - false: [pkg.exports.default.import, pkg.exports.default.require, []], - true: [pkg.exports.browser.import, pkg.exports.browser.require, [terser()]] - }[CLIENT]; +const getCommon = ({ config_isClient }) => ({ + input: { + main: "./src/index.js", + lazyComponentLoadStrategyFactory: + "./src/lazyComponentLoadStrategyFactory.js" + }, + external: ["react/jsx-runtime"], + plugins: [ + keepExternalComments, + babel({ + exclude: [/node_modules/], + babelHelpers: "runtime" + }), + resolve({ + preferBuiltins: true + }), + commonjs(), + external(), + ...[JSON.parse(config_isClient) ? terser() : []] + ], + preserveSymlinks: true +}); - return { - input: "./src/index.js", - output: [ - { - file: esOutputFile, +export default (args) => { + const isClient = JSON.parse(args.config_isClient); + const common = getCommon(args); + return [ + { + ...common, + output: { + dir: "lib/", + exports: "named", format: "es", + entryFileNames: `[name]${isClient ? ".browser" : ""}.js`, sourcemap: true - }, - { - file: cjsOutputFile, + } + }, + { + ...common, + output: { + dir: "lib/", + exports: "named", format: "cjs", + entryFileNames: `[name]${isClient ? ".browser" : ""}.es5.cjs`, sourcemap: true - } - ], - external: ["react/jsx-runtime"], - plugins: [ - keepExternalComments, - babel({ - exclude: [/node_modules/], - babelHelpers: "runtime" - }), - resolve({ - preferBuiltins: true - }), - commonjs(), - external(), - ...extraPlugins - ], - preserveSymlinks: true - }; + }, + plugins: [ + ...common.plugins, + replace({ + preventAssignment: true, + values: { + "import.meta.filename": "__filename" + } + }) + ] + } + ]; }; diff --git a/packages/react-pointcuts/docs/CHANGELOG.md b/packages/react-pointcuts/docs/CHANGELOG.md index f79abb4..93668cf 100644 --- a/packages/react-pointcuts/docs/CHANGELOG.md +++ b/packages/react-pointcuts/docs/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - ????-??-?? + +### Added + +- support for "packed" modules in the `withToggledPointFactory` and `withToggledHookFactory` + - this allows for load strategies to store variations in a form that prevents early download and/or execution, providing a means to "unpack" the module when it is selected / actually ready to render + - the update to `withToggledHookFactory` allows for "deferred execution" loading strategies, but does not support code-split / lazy loading, since no way to inject suspense boundaries within execution path of hooks +- detection of modules packed with [`React.lazy`](https://react.dev/reference/react/lazy) in the `withTogglePointFactory` + - wraps the lazy loaded components in a [Suspense boundary](https://react.dev/reference/react/Suspense) that preserves server-rendered markup whilst variant bundles are downloading. + - utilises [useDeferredValue](https://react.dev/reference/react/useDeferredValue) where available (React 18+) to ensure that when changing between variants (i.e. dynamic feature stores) the existing variant is preserved whilst new variants download, preventing `null`-rendering flash of no content +- a `lazyComponentLoadStrategyFactory` path export + - this creates load strategies for the webpack plugin to "pack" components in `React.lazy` + - added dependency on the webpack package, to support this + +### Changed + +- updated the interface of `withTogglePoint` to de-structure an object, rather than have multiple parameters, aligning with change to the Webpack Plugin, made to support toggle points that only care about a `featuresMap`, or perhaps aligned to a load strategy that does not need `pack` and/or `unpack`. This also aligns with ASOS Codebase Convention PC14 +- clarified the features store structure that the toggle points are compatible with + ## [0.5.1] - 2025-11-14 ### Fixed diff --git a/packages/react-pointcuts/docs/README.md b/packages/react-pointcuts/docs/README.md index 0a85ad7..b437e44 100644 --- a/packages/react-pointcuts/docs/README.md +++ b/packages/react-pointcuts/docs/README.md @@ -1,28 +1,45 @@ # @asos/web-toggle-point-react-pointcuts -This package provides react application pointcut code, acting as a target toggle point injected by [`@asos/web-toggle-point-webpack/plugin`](../../webpack/docs/README.md) +This package provides react application pointcut code, acting as a target toggle point injected by the [`TogglePointInjectionPlugin`](../../webpack/docs/README.md). -It contains the following exports: +It is designed for a features store that has "features" and "variants" of features, held in a two-level `Map`. + +It contains the following exports from the base package (`@asos/web-toggle-point-react-pointcuts`): - `withTogglePointFactory` -This is a factory function used to create a `withTogglePoint` [react higher-order component](https://reactjs.org/docs/higher-order-components.html). +This is a factory function used to create a `withTogglePoint` [react higher-order component](https://reactjs.org/docs/higher-order-components.html). - `withToggledHookFactory` This is a factory function used to create a [hook](https://reactjs.org/docs/hooks-intro.html)-wrapping function. -The product of both these factories receive the outcome of the webpack plugin, used to choose appropriate variants at runtime, based on decisions from a supplied context. +The product of both these factories receive the outcome of the webpack plugin, used to choose appropriate variants at runtime, based on decisions from a supplied context. Both accept plugins, currently supporting a hook called during code activation (mounting of the component, or calling the hook). +It also contains a package export: + +- `@asos/web-toggle-point-react-pointcuts/lazyComponentLoadStrategyFactory` + +This is a factory for a load strategy for use with the webpack [`TogglePointInjectionPlugin`](../../webpack/docs/README.md) for when lazy-loading / code splitting is desired. + +> [!NOTE] +> +> In SSR scenarios, a streaming render should be used (i.e. [`renderToPipeableStream`](https://react.dev/reference/react-dom/server/renderToPipeableStream) or [`renderToReadableStream`](https://react.dev/reference/react-dom/server/renderToReadableStream)) when using this load strategy, as is inherent with [`React.lazy`](https://react.dev/reference/react/lazy), otherwise the following error will be produced: +> +> > Error: A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with `startTransition`. + ## Usage +The package has a peer dependency requirement of [`react`](https://github.com/facebook/react/tree/main/packages/react) (with version-matched [`react-is`](https://github.com/facebook/react/tree/main/packages/react-is)), and should work with React 17 and above. + See: [JSDoc output](https://asos.github.io/web-toggle-point/module-web-toggle-point-react-pointcuts.html) > [!WARNING] > ### Use with React 17 -> The package should work with React 17 and above, but due to [a bug](https://github.com/facebook/react/issues/20235) that they are not back-filling, the use of `"type": "module"` in the package means webpack will be unable to resolve the extensionless import. +> The package should work with React 17 and above, but due to [a bug](https://github.com/facebook/react/issues/20235) that they are not back-filling, the use of `"type": "module"` +> in the package means webpack will be unable to resolve the extensionless import. > To fix, either upgrade to React 18+ or add the following resolve configuration to the webpack config: > ```js > resolve: { @@ -32,3 +49,23 @@ See: [JSDoc output](https://asos.github.io/web-toggle-point/module-web-toggle-po > } > } > ``` + +> [!IMPORTANT] +> +> Since React 17 does not support suspense for code splitting during server-side rendering, where a `lazyComponentLoadStrategyFactory` strategy is used, this will preclude the use of Server-Side Rendering. You will receive the following: +> +> > ReactDOMServer does not yet support Suspense. + +> [!WARNING] +> ### Use with NextJS +> The package will work with NextJs (see caveats in [next example](../../../examples/next/README.md)), but since NextJs uses it's own "pre-compiled" versions of +> `react` and `react-is`, the `Symbol` used to mark react types needs to be aligned, since this package uses `react-is` to detect if a toggled component is "lazy" or not. +> To ensure that the next-specific version of `react-is` is used by the application build, it can be aliased in the webpack config of the NextJs app thus: +> ```js +> resolve: { +> alias: { +> "react-is": `next/dist/compiled/react-is/cjs/react-is.${process.env.NODE_ENV === "production" ? "production" : "development"}.js` +> } +>} +> ``` +> N.B. The compiled entrypoint for CJS doesn't re-export named exports properly, so you'll need to select the production or development build as appropriate, as shown. The above works for Next 15, for Next 14 the production bundle is `react-is.production.min.js` diff --git a/packages/react-pointcuts/jest.config.js b/packages/react-pointcuts/jest.config.js deleted file mode 100644 index 14ba02e..0000000 --- a/packages/react-pointcuts/jest.config.js +++ /dev/null @@ -1,4 +0,0 @@ -export default { - testEnvironment: "jsdom", - setupFilesAfterEnv: ["./jest.setup-after-env.js"] -}; diff --git a/packages/react-pointcuts/jest.config.json b/packages/react-pointcuts/jest.config.json new file mode 100644 index 0000000..3200bde --- /dev/null +++ b/packages/react-pointcuts/jest.config.json @@ -0,0 +1,7 @@ +{ + "testEnvironment": "jsdom", + "setupFilesAfterEnv": ["./jest.setup-after-env.js"], + "transform": { + "\\.js$": ["babel-jest", { "configFile": "./babel.jest.json" }] + } +} \ No newline at end of file diff --git a/packages/react-pointcuts/package.json b/packages/react-pointcuts/package.json index 0624330..5ecd86a 100644 --- a/packages/react-pointcuts/package.json +++ b/packages/react-pointcuts/package.json @@ -1,18 +1,26 @@ { "name": "@asos/web-toggle-point-react-pointcuts", "description": "react pointcut code", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "type": "module", "main": "./lib/main.es5.cjs", "exports": { - "browser": { - "import": "./lib/browser.js", - "require": "./lib/browser.es5.cjs" - }, - "default": { + ".": { + "browser": { + "import": "./lib/main.browser.js", + "require": "./lib/main.browser.es5.cjs" + }, "import": "./lib/main.js", "require": "./lib/main.es5.cjs" + }, + "./lazyComponentLoadStrategyFactory": { + "browser": { + "import": "./lib/lazyComponentLoadStrategyFactory.browser.js", + "require": "./lib/lazyComponentLoadStrategyFactory.browser.es5.cjs" + }, + "import": "./lib/lazyComponentLoadStrategyFactory.js", + "require": "./lib/lazyComponentLoadStrategyFactory.es5.cjs" } }, "keywords": [ @@ -34,6 +42,8 @@ "doc": "docs" }, "scripts": { + "build-dependencies": "path-exists ../../packages/webpack/lib || npm run --prefix ../../packages/webpack build", + "prebuild": "npm run build-dependencies", "build": "npm run clean && npm run build:browser && npm run build:server", "build:browser": "cross-env BABEL_ENV=browser rollup -c build/rollup.mjs --config_isClient true", "build:server": "rollup -c build/rollup.mjs --config_isClient false", @@ -43,9 +53,11 @@ "docs": "rimraf ./docs/**/*.html && jsdoc -c ../jsdoc.conf.js", "lint": "eslint build src docs --flag v10_config_lookup_from_file", "lint:fix": "npm run lint -- --fix", + "pretest": "npm run build-dependencies", "test": "jest" }, "dependencies": { + "@asos/web-toggle-point-webpack": "file:../webpack", "@babel/runtime": "^7.26.0" }, "devDependencies": { @@ -57,6 +69,7 @@ "@testing-library/jest-dom": "^6.1.3", "@testing-library/react": "^16.0.1", "@types/webpack-env": "^1.18.0", + "babel-plugin-transform-import-meta-x": "^0.0.3", "eslint-plugin-jsdoc": "^50.5.0", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.31.10", @@ -67,6 +80,7 @@ "jest-extended": "^4.0.2", "jsdoc": "^4.0.4", "react": "^18.3.1", + "react-is": "^18.3.1", "rimraf": "^6.0.1", "rollup-plugin-auto-external": "^2.0.0", "shx": "^0.4.0", @@ -74,8 +88,8 @@ }, "peerDependencies": { "@asos/web-toggle-point-features": "file:../features", - "@asos/web-toggle-point-webpack": "file:../webpack", "prop-types": "^15.7.2", - "react": ">=17" + "react": ">=17", + "react-is": ">=17" } } diff --git a/packages/react-pointcuts/src/lazyComponentLoadStrategyFactory.js b/packages/react-pointcuts/src/lazyComponentLoadStrategyFactory.js new file mode 100644 index 0000000..cef467b --- /dev/null +++ b/packages/react-pointcuts/src/lazyComponentLoadStrategyFactory.js @@ -0,0 +1,28 @@ +import { lazy } from "react"; +// eslint-disable-next-line import/no-unresolved -- https://github.com/import-js/eslint-plugin-import/issues/1810 +import deferredDynamicImportLoadStrategyFactory from "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory"; + +const adapterModuleSpecifier = import.meta.filename; + +export const pack = (expression) => lazy(expression); + +/** + * A component load strategy factory to generate a load strategy using React's lazy function. Wraps the {@link module:web-toggle-point-webpack.deferredDynamicImportLoadStrategyFactory|deferredDynamicImportLoadStrategyFactory} from the Webpack package + * @memberof module:web-toggle-point-react-pointcuts + * @param {object} [options] options + * @param {object} [options.importCodeGeneratorFactoryOptions] options for the code generator factory. see {@link module:web-toggle-point-webpack.deferredDynamicImportLoadStrategyFactory|deferredDynamicImportLoadStrategyFactory} + * @param {string} [options.importCodeGeneratorFactoryOptions.webpackMagicComment] a magic comment prefixing the {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import|dynamic import} statement. see {@link https://webpack.js.org/api/module-methods/#magic-comments|Webpack Magic Comments} + * @returns {module:web-toggle-point-webpack.loadStrategy} a load strategy + * @see {@link https://reactjs.org/docs/code-splitting.html#reactlazy|React.lazy} + */ +const lazyComponentLoadStrategyFactory = (options) => + /** + * lazyComponentLoadStrategy + * @implements module:web-toggle-point-webpack.loadStrategy + */ + ({ + ...deferredDynamicImportLoadStrategyFactory(options), + adapterModuleSpecifier + }); + +export default lazyComponentLoadStrategyFactory; diff --git a/packages/react-pointcuts/src/lazyComponentLoadStrategyFactory.test.js b/packages/react-pointcuts/src/lazyComponentLoadStrategyFactory.test.js new file mode 100644 index 0000000..1f14f4f --- /dev/null +++ b/packages/react-pointcuts/src/lazyComponentLoadStrategyFactory.test.js @@ -0,0 +1,65 @@ +/* eslint-disable import/namespace */ +import lazyComponentLoadStrategyFactory, * as namespace from "./lazyComponentLoadStrategyFactory.js"; +// eslint-disable-next-line import/no-unresolved -- https://github.com/import-js/eslint-plugin-import/issues/1810 +import deferredDynamicImportLoadStrategyFactory from "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory"; +import { lazy } from "react"; + +const mockLazyResult = Symbol("test-lazy-result"); +jest.mock("react", () => ({ + lazy: jest.fn(() => mockLazyResult) +})); + +const mockImportCodeGenerator = Symbol("test-import-code-generator"); +jest.mock( + "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory", + () => + jest.fn(() => ({ + importCodeGenerator: mockImportCodeGenerator + })) +); + +describe("lazyComponentLoadStrategyFactory", () => { + const options = Symbol("test-options"); + let result; + + beforeEach(() => { + result = lazyComponentLoadStrategyFactory(options); + }); + + it("should call the deferredDynamicImportLoadStrategyFactory with the options passed to it", () => { + expect(deferredDynamicImportLoadStrategyFactory).toHaveBeenCalledWith( + options + ); + }); + + it("should return an object containing the adapterModuleSpecifier, indicating the location of the factory code file (so that named exports for the pack and unpack functions can be included in the webpack compilation) and importCodeGenerator", () => { + expect(result).toEqual( + expect.objectContaining({ + adapterModuleSpecifier: expect.stringMatching( + /packages([/\\])react-pointcuts\1src\1lazyComponentLoadStrategyFactory\.js$/ + ) + }) + ); + }); + + it("should return the importCodeGenerator from the deferredDynamicImportLoadStrategyFactory", () => { + expect(result).toEqual( + expect.objectContaining({ + importCodeGenerator: mockImportCodeGenerator + }) + ); + }); + + it("should export a pack function that wraps its input in React.lazy", () => { + const expression = Symbol("test-expression"); + const packResult = namespace.pack(expression); + expect(lazy).toHaveBeenCalledWith(expression); + expect(packResult).toBe(mockLazyResult); + }); + + describe("unpack", () => { + it("should not export an unpack function, so that the default (identity function) is used", () => { + expect(namespace.unpack).toBe(undefined); + }); + }); +}); diff --git a/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.js b/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.js index 7f3fb15..bda5115 100644 --- a/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.js +++ b/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.js @@ -3,10 +3,10 @@ const getMatchedVariant = ({ matchedFeatures, featuresMap, variantKey }) => { feature, { [variantKey]: variant, ...variables } ] of matchedFeatures) { - const codeRequest = featuresMap.get(feature)?.get(variant); - if (codeRequest) { + const packedModule = featuresMap.get(feature)?.get(variant); + if (packedModule) { return { - codeRequest, + packedModule, variables }; } diff --git a/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.test.js b/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.test.js index d632b15..7b8301b 100644 --- a/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.test.js +++ b/packages/react-pointcuts/src/useCodeMatches/getMatchedVariant.test.js @@ -32,11 +32,11 @@ describe("getMatchedVariant", () => { [feature3, { bucket: bucket1 }] ]; - it("should return the first matching variant where a folder and file match exists in the features map, and return the code request, and the associated variables from the matched features", () => { + it("should return the first matching variant where a folder and file match exists in the features map, and return the packed module, and the associated variables from the matched features", () => { expect( getMatchedVariant({ matchedFeatures, featuresMap, variantKey }) ).toEqual({ - codeRequest: featuresMap.get(feature1).get(bucket2), + packedModule: featuresMap.get(feature1).get(bucket2), variables }); }); diff --git a/packages/react-pointcuts/src/useCodeMatches/index.js b/packages/react-pointcuts/src/useCodeMatches/index.js index 6b647b0..3ed7047 100644 --- a/packages/react-pointcuts/src/useCodeMatches/index.js +++ b/packages/react-pointcuts/src/useCodeMatches/index.js @@ -3,7 +3,7 @@ import getMatchedVariant from "./getMatchedVariant"; import getMatchedFeatures from "./getMatchedFeatures"; const useCodeMatches = ({ activeFeatures, variantKey, featuresMap }) => { - const seriazlizedActiveFeatures = JSON.stringify(activeFeatures); + const serializedActiveFeatures = JSON.stringify(activeFeatures); const matches = useMemo(() => { const matchedFeatures = getMatchedFeatures({ @@ -20,7 +20,7 @@ const useCodeMatches = ({ activeFeatures, variantKey, featuresMap }) => { matchedFeatures, matchedVariant }; - }, [seriazlizedActiveFeatures]); // eslint-disable-line react-hooks/exhaustive-deps + }, [serializedActiveFeatures]); // eslint-disable-line react-hooks/exhaustive-deps return matches; }; diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.js b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.js index 113a4da..39a3487 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.js @@ -1,37 +1,34 @@ import withCodeSelectionPlugins from "./withCodeSelectionPlugins"; import withErrorBoundary from "./withErrorBoundary"; import { forwardRef } from "react"; +import getDisplayName from "../getDisplayName"; const getControlOrVariant = ({ matchedFeatures, matchedVariant, onVariantError, - control + packedBaseModule, + unpackComponent }) => { - if (!matchedFeatures.length) { - return control; + if (!matchedFeatures.length || !matchedVariant) { + return unpackComponent(packedBaseModule); } - let Component = control; - if (matchedVariant) { - const { codeRequest, variables } = matchedVariant; - const { default: VariantWithoutVariables } = codeRequest; - const Variant = forwardRef((props, ref) => ( - - )); - Variant.displayName = `Variant(${ - VariantWithoutVariables.displayName || - VariantWithoutVariables.name || - "Component" - })`; - - Component = withErrorBoundary({ - Variant, - onVariantError, - fallback: control - }); - } - return Component; + const { packedModule, variables } = matchedVariant; + const VariantWithoutVariables = unpackComponent(packedModule); + const Variant = forwardRef((props, ref) => ( + + )); + Variant.displayName = `Variant(${getDisplayName(VariantWithoutVariables)})`; + + const component = withErrorBoundary({ + Variant, + onVariantError, + packedBaseModule, + unpackComponent + }); + + return component; }; const getComponent = (params) => { diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.test.js b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.test.js index 41ebf64..3231dca 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.test.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/index.test.js @@ -3,15 +3,24 @@ import withErrorBoundary from "./withErrorBoundary"; import withCodeSelectionPlugins from "./withCodeSelectionPlugins"; import { render, screen } from "@testing-library/react"; import { createRef, forwardRef } from "react"; +import getDisplayName from "../getDisplayName"; jest.mock("./withErrorBoundary", () => jest.fn()); jest.mock("./withCodeSelectionPlugins", () => jest.fn()); +const mockDisplayName = "test-display-name"; +jest.mock("../getDisplayName", () => jest.fn(() => mockDisplayName)); const mockVariantComponent = "test-variant-component"; const MockVariantComponent = forwardRef( jest.fn((_, ref) =>
) ); +const unpackMarker = Symbol("test-unpack-marker"); +const unpackComponent = jest.fn().mockImplementation((module) => { + module[unpackMarker] = true; + return module; +}); + describe("getComponent", () => { let result, params; const pluginMarker = Symbol("test-plugin-marker"); @@ -19,8 +28,9 @@ describe("getComponent", () => { beforeEach(() => { jest.clearAllMocks(); params = { - control: () => Symbol("test-base-component"), + packedBaseModule: () => Symbol("test-base-component"), codeSelectionPlugins: Symbol("test-code-selection-plugins"), + unpackComponent, variantErrorPlugins: [ { onVariantError: jest.fn(() => { @@ -36,140 +46,179 @@ describe("getComponent", () => { }); }); - const makeCommonAssertions = () => { - it("should run plugins on the the matched features, passing the params that were passed to the component, in-case the plugins need them", () => { - const { plugins, ...rest } = params; - expect(withCodeSelectionPlugins).toHaveBeenCalledWith({ - Component: result, - plugins, - ...rest + const makeCommonAssertions = (makeExtraAssertions = () => {}) => { + const makeFallbackAssertion = () => { + it("should return the unpacked base component", () => { + expect(unpackComponent).toHaveBeenCalledWith(params.packedBaseModule); + expect(result).toBe(params.packedBaseModule); + expect(result[unpackMarker]).toBe(true); }); - expect(result[pluginMarker]).toBe(true); - }); - }; - - const makeFallbackAssertion = () => { - it("should return the control (base) component", () => { - expect(result).toBe(params.control); - }); - }; - - describe("when there are no matched features", () => { - beforeEach(() => { - params = { - ...params, - matchedFeatures: [], - matchedVariant: null - }; - result = getComponent(params); - }); - - makeCommonAssertions(); - makeFallbackAssertion(); - }); - - describe("when there are matched features", () => { - const matchedFeatures = [Symbol("test-matched-feature")]; - beforeEach(() => { - params = { - ...params, - matchedFeatures - }; - }); + }; - describe("and there is no matched variant", () => { + describe("when there are no matched features", () => { beforeEach(() => { params = { ...params, + matchedFeatures: [], matchedVariant: null }; result = getComponent(params); }); - makeCommonAssertions(); + makeExtraAssertions(); makeFallbackAssertion(); }); - describe("and there is a matched variant", () => { - const errorBoundariedMarker = Symbol("test-error-boundaried-marker"); - const innateProp = "test-innate-prop"; - const variables = { - "test-variable-key": Symbol("test-variable-value-1"), - [innateProp]: Symbol("test-variable-value-2") - }; - + describe("when there are matched features", () => { + const matchedFeatures = [Symbol("test-matched-feature")]; beforeEach(() => { - withErrorBoundary.mockImplementation(({ Variant }) => { - Variant[errorBoundariedMarker] = true; - - return Variant; - }); params = { ...params, - matchedVariant: { - codeRequest: { default: MockVariantComponent }, - variables - } + matchedFeatures }; result = getComponent(params); }); - it("should wrap the variant with an error boundary, to ensure errors in the variant result in falling back to the base/default component", () => { - const { control: Component } = params; - expect(withErrorBoundary).toHaveBeenCalledWith({ - onVariantError: expect.any(Function), - Variant: expect.anything(), - fallback: Component + describe("and there is no matched variant", () => { + beforeEach(() => { + params = { + ...params, + matchedVariant: null + }; + result = getComponent(params); }); - expect(result[errorBoundariedMarker]).toBe(true); - }); - makeCommonAssertions(); + makeExtraAssertions(); + makeFallbackAssertion(); + }); - describe("when the returned variant is rendered", () => { - const componentProps = { - "test-component-prop": Symbol("test-component-prop-value"), - [innateProp]: Symbol("test-innate-prop-value") + describe("and there is a matched variant", () => { + const errorBoundariedMarker = Symbol("test-error-boundaried-marker"); + const innateProp = "test-innate-prop"; + const variables = { + "test-variable-key": Symbol("test-variable-value-1"), + [innateProp]: Symbol("test-variable-value-2") }; - const mockRef = createRef(); beforeEach(() => { - const Component = result; - render(); + withErrorBoundary.mockImplementation(({ Variant }) => { + Variant[errorBoundariedMarker] = true; + + return Variant; + }); + params = { + ...params, + matchedVariant: { + packedModule: MockVariantComponent, + variables + } + }; + result = getComponent(params); }); - it("should return the variant, sending down any innate props, attaching the passed ref, and any variables from the feature as props (preferring innate props over variables - so features can't overwrite innate/in-built props)", () => { - const variantElement = screen.getByTestId(mockVariantComponent); - expect(variantElement).toBeInTheDocument(); - expect(variantElement).toBe(mockRef.current); - expect(MockVariantComponent.render).toHaveBeenCalledWith( - expect.objectContaining({ ...variables, ...componentProps }), - mockRef - ); + it("should wrap the variant with an error boundary, to ensure errors in the variant result in falling back to the base/default component", () => { + const { packedBaseModule: Component } = params; + expect(withErrorBoundary).toHaveBeenCalledWith({ + onVariantError: expect.any(Function), + Variant: expect.anything(), + packedBaseModule: Component, + unpackComponent + }); + expect(result[errorBoundariedMarker]).toBe(true); }); - }); - describe("when the variant throws an error, thus the error boundary calls the passed onVariantError method", () => { - const mockError = Symbol("test-error"); - const syncMethod = jest.fn(); + it("should set a display name on the variant", () => { + const [[{ Variant }]] = withErrorBoundary.mock.calls; + expect(getDisplayName).toHaveBeenCalledWith(MockVariantComponent); + expect(Variant.displayName).toEqual(`Variant(${mockDisplayName})`); + }); - beforeEach(() => { - const [[{ onVariantError }]] = withErrorBoundary.mock.calls; - onVariantError(mockError); - syncMethod(); + makeExtraAssertions(); + + describe("when the returned component is rendered", () => { + const componentProps = { + "test-component-prop": Symbol("test-component-prop-value"), + [innateProp]: Symbol("test-innate-prop-value") + }; + const mockRef = createRef(); + + beforeEach(() => { + const Component = result; + render(); + }); + + it("should return the unpacked variant component", () => { + expect(unpackComponent).toHaveBeenCalledWith( + params.matchedVariant.packedModule + ); + expect(MockVariantComponent[unpackMarker]).toBe(true); + }); + + it("should render the variant, sending down any innate props, attaching the passed ref, and any variables from the feature as props (preferring innate props over variables - so features can't overwrite innate/in-built props)", () => { + const variantElement = screen.getByTestId(mockVariantComponent); + expect(variantElement).toBeInTheDocument(); + expect(variantElement).toBe(mockRef.current); + expect(MockVariantComponent.render).toHaveBeenCalledWith( + expect.objectContaining({ ...variables, ...componentProps }), + mockRef + ); + }); }); - it("should call the onVariantError callbacks with the error, with the callbacks not holding up execution of the main thread and errors thrown not affecting subsequent callbacks", () => { - const [ - { onVariantError: plugin1Callback }, - { onVariantError: plugin2Callback } - ] = params.variantErrorPlugins; - expect(plugin1Callback).toHaveBeenCalledWith(mockError); - expect(plugin2Callback).toHaveBeenCalledWith(mockError); - expect(syncMethod).toHaveBeenCalledBefore(plugin1Callback); - expect(syncMethod).toHaveBeenCalledBefore(plugin2Callback); + describe("when the variant throws an error, thus the error boundary calls the supplied onVariantError method", () => { + const mockError = Symbol("test-error"); + const syncMethod = jest.fn(); + + beforeEach(() => { + const [[{ onVariantError }]] = withErrorBoundary.mock.calls; + onVariantError(mockError); + syncMethod(); + }); + + it("should call the onVariantError callbacks with the error, with the callbacks not holding up execution of the main thread and errors thrown not affecting subsequent callbacks", () => { + const [ + { onVariantError: plugin1Callback }, + { onVariantError: plugin2Callback } + ] = params.variantErrorPlugins; + expect(plugin1Callback).toHaveBeenCalledWith(mockError); + expect(plugin2Callback).toHaveBeenCalledWith(mockError); + expect(syncMethod).toHaveBeenCalledBefore(plugin1Callback); + expect(syncMethod).toHaveBeenCalledBefore(plugin2Callback); + }); + }); + }); + }); + }; + + describe("when plugins are passed", () => { + beforeEach(() => { + params = { + ...params, + codeSelectionPlugins: Symbol("test-plugins") + }; + }); + + makeCommonAssertions(() => { + it("should run plugins on the the matched features, passing the params that were passed to the component, in-case the plugins need them", () => { + const { codeSelectionPlugins, ...rest } = params; + expect(withCodeSelectionPlugins).toHaveBeenCalledWith({ + Component: result, + codeSelectionPlugins, + ...rest }); + expect(result[pluginMarker]).toBe(true); }); }); }); + + describe("when no plugins are passed", () => { + beforeEach(() => { + params = { + ...params, + codeSelectionPlugins: null + }; + }); + + makeCommonAssertions(); + }); }); diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.js b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.js index 1ccf6a0..76c27a3 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.js @@ -1,4 +1,5 @@ import { forwardRef } from "react"; +import getDisplayName from "../getDisplayName"; const wrap = ({ Component, useHook, name }, rest) => { const WithTogglePointPlugin = forwardRef((props, ref) => { @@ -7,9 +8,9 @@ const wrap = ({ Component, useHook, name }, rest) => { return ; }); - WithTogglePointPlugin.displayName = `With${name}(${ - Component.displayName || Component.name || "Component" - })`; + WithTogglePointPlugin.displayName = `With${name}(${getDisplayName( + Component + )})`; return WithTogglePointPlugin; }; diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.test.js b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.test.js index 92426b7..739e18b 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.test.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withCodeSelectionPlugins.test.js @@ -1,6 +1,9 @@ import { render } from "@testing-library/react"; import withCodeSelectionPlugins from "./withCodeSelectionPlugins"; import { forwardRef } from "react"; +import getDisplayName from "../getDisplayName"; + +jest.mock("../getDisplayName", () => jest.fn(({ displayName }) => displayName)); describe("withCodeSelectionPlugins", () => { const codeSelectionPlugins = [ @@ -15,54 +18,42 @@ describe("withCodeSelectionPlugins", () => { const TestComponent = forwardRef(() => (
)); + TestComponent.displayName = "test-display-name"; - const makeCommonAssertions = () => { - it("should execute the 'onCodeSelected' hooks of all plugins, in reverse order (since first plugin applies closest to the wrapped component), passing the props passed to withPlugins", () => { - let lastPlugin; - codeSelectionPlugins.forEach((plugin) => { - expect(plugin.onCodeSelected).toHaveBeenCalledTimes(1); - expect(plugin.onCodeSelected).toHaveBeenCalledWith(rest); - if (lastPlugin) { - // eslint-disable-next-line jest/no-conditional-expect - expect(plugin.onCodeSelected).toHaveBeenCalledBefore( - lastPlugin.onCodeSelected - ); - } - lastPlugin = plugin; - }); - }); - }; + let Wrapped; - describe.each` - displayName | name | expectedDisplayName - ${"TestComponentDisplayName"} | ${"TestComponentName"} | ${"TestComponentDisplayName"} - ${null} | ${"TestComponentName"} | ${"TestComponentName"} - ${null} | ${null} | ${"Component"} - `( - "when the component has a name of $name and a display name of $displayName", - ({ displayName, name, expectedDisplayName }) => { - let Wrapped; + beforeEach(() => { + jest.clearAllMocks(); + Wrapped = withCodeSelectionPlugins({ + Component: TestComponent, + codeSelectionPlugins, + ...rest + }); + render(); + }); - beforeEach(() => { - jest.clearAllMocks(); - TestComponent.displayName = displayName; - TestComponent.name = name; + it("should get the display name of the wrapped component", () => { + expect(getDisplayName).toHaveBeenCalledWith(TestComponent); + }); - Wrapped = withCodeSelectionPlugins({ - Component: TestComponent, - codeSelectionPlugins, - ...rest - }); - render(); - }); + it("should have a display name that wraps the component in the code selection plugins, in order", () => { + expect(Wrapped.displayName).toBe( + `With${codeSelectionPlugins[1].name}(With${codeSelectionPlugins[0].name}(${TestComponent.displayName}))` + ); + }); - it("should have a display name that wraps the component in the code selection plugins, in order", () => { - expect(Wrapped.displayName).toBe( - `With${codeSelectionPlugins[1].name}(With${codeSelectionPlugins[0].name}(${expectedDisplayName}))` + it("should execute the 'onCodeSelected' hooks of all plugins, in reverse order (since first plugin applies closest to the wrapped component), passing the props passed to withPlugins", () => { + let lastPlugin; + codeSelectionPlugins.forEach((plugin) => { + expect(plugin.onCodeSelected).toHaveBeenCalledTimes(1); + expect(plugin.onCodeSelected).toHaveBeenCalledWith(rest); + if (lastPlugin) { + // eslint-disable-next-line jest/no-conditional-expect + expect(plugin.onCodeSelected).toHaveBeenCalledBefore( + lastPlugin.onCodeSelected ); - }); - - makeCommonAssertions(); - } - ); + } + lastPlugin = plugin; + }); + }); }); diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.js b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.js index 468696d..4f36e1e 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.js @@ -1,8 +1,14 @@ import { Component, forwardRef, createContext } from "react"; +import getDisplayName from "../getDisplayName"; const ForwardedRefContext = createContext(); -const withErrorBoundary = ({ Variant, onVariantError, fallback }) => { +const withErrorBoundary = ({ + Variant, + packedBaseModule, + onVariantError, + unpackComponent +}) => { class TogglePointErrorBoundary extends Component { constructor(props) { super(props); @@ -21,7 +27,9 @@ const withErrorBoundary = ({ Variant, onVariantError, fallback }) => { static contextType = ForwardedRefContext; render() { - const Component = this.state.hasError ? fallback : Variant; + const Component = this.state.hasError + ? unpackComponent(packedBaseModule) + : Variant; return ; } @@ -32,9 +40,9 @@ const withErrorBoundary = ({ Variant, onVariantError, fallback }) => { )); - TogglePointErrorBoundaryWithRef.displayName = `withErrorBoundary(${ - Variant.displayName || Variant.name || "Component" - })`; + TogglePointErrorBoundaryWithRef.displayName = `withErrorBoundary(${getDisplayName( + Variant + )})`; return TogglePointErrorBoundaryWithRef; }; diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.test.js b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.test.js index f61e204..8cde518 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.test.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/getComponent/withErrorBoundary.test.js @@ -2,14 +2,18 @@ import withErrorBoundary from "./withErrorBoundary"; import { render, screen } from "@testing-library/react"; import { createRef, forwardRef } from "react"; +import getDisplayName from "../getDisplayName"; const mockOnVariantError = jest.fn(); +const mockDisplayName = "test-display-name"; +jest.mock("../getDisplayName", () => jest.fn(() => mockDisplayName)); + describe("withErrorBoundary", () => { const inboundProps = { "test-prop": Symbol("test-value") }; - const mockFallback = "test-mock-fallback"; - const MockFallback = forwardRef( - jest.fn((_, ref) =>
) + const mockBaseModule = "test-mock-base-module"; + const MockBaseModule = forwardRef( + jest.fn((_, ref) =>
) ); const mockVariant = "test-mock-variant"; const MockVariant = forwardRef( @@ -17,6 +21,7 @@ describe("withErrorBoundary", () => { ); const mockErrorMessage = "test-error"; const mockRef = createRef(); + const unpackComponent = jest.fn((module) => module); let mockError; const MockErrorVariant = () => { throw mockError; @@ -28,104 +33,87 @@ describe("withErrorBoundary", () => { }); let Boundaried; - const makeCommonAssertions = (MockVariant) => { - describe("when rendering the variant and no error occurs", () => { - beforeEach(() => { - Boundaried = withErrorBoundary({ - Variant: MockVariant, - fallback: MockFallback, - onVariantError: mockOnVariantError - }); - render(); - }); - it("should render the variant, passing the inbound props, and not render the fallback", () => { - const variantElement = screen.getByTestId(mockVariant); - expect(variantElement).toBeInTheDocument(); - expect(variantElement).toBe(mockRef.current); - expect(MockVariant.render).toHaveBeenCalledWith(inboundProps, mockRef); - expect(screen.queryByTestId(mockFallback)).not.toBeInTheDocument(); - }); + const makeCommonAssertions = () => { + it("should have a display name that wraps the component in the error boundary", () => { + expect(Boundaried.displayName).toBe( + `withErrorBoundary(${mockDisplayName})` + ); + }); + }; - it("should not call the onVariantError callback", () => { - expect(mockOnVariantError).not.toHaveBeenCalled(); + describe("when rendering the variant and no error occurs", () => { + beforeEach(() => { + Boundaried = withErrorBoundary({ + Variant: MockVariant, + packedBaseModule: MockBaseModule, + onVariantError: mockOnVariantError, + unpackComponent }); + render(); }); - describe("when rendering the variant and an error occurs", () => { - beforeEach(() => { - jest.spyOn(console, "error").mockImplementation(() => {}); - const Boundaried = withErrorBoundary({ - Variant: MockErrorVariant, - fallback: MockFallback, - onVariantError: mockOnVariantError - }); - render(); - }); + makeCommonAssertions(); - afterEach(() => { - console.error.mockRestore(); - }); + it("should get the display name of the variant passed", () => { + expect(getDisplayName).toHaveBeenCalledWith(MockVariant); + }); - it("should render the fallback, passing the inbound props, and no longer render the variant", () => { - const fallbackElement = screen.getByTestId(mockFallback); - expect(fallbackElement).toBeInTheDocument(); - expect(fallbackElement).toBe(mockRef.current); - expect(MockFallback.render).toHaveBeenCalledWith(inboundProps, mockRef); - expect(screen.queryByTestId(mockVariant)).not.toBeInTheDocument(); - }); + it("should render the variant, passing the inbound props, and not render the fallback", () => { + const variantElement = screen.getByTestId(mockVariant); + expect(variantElement).toBeInTheDocument(); + expect(variantElement).toBe(mockRef.current); + expect(MockVariant.render).toHaveBeenCalledWith(inboundProps, mockRef); + expect(screen.queryByTestId(mockBaseModule)).not.toBeInTheDocument(); + }); - it("should call the onVariantError callback, indicating that the fallback has been rendered", () => { - expect(mockOnVariantError).toHaveBeenCalledWith(mockError); - }); + it("should not call the onVariantError callback", () => { + expect(mockOnVariantError).not.toHaveBeenCalled(); + }); + }); - it("should update the message on the error to include that the variant has errored", () => { - expect(mockError.message).toBe( - `Variant errored, rendering fallback: ${mockErrorMessage}` - ); + describe("when rendering the variant and an error occurs", () => { + beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => {}); + const Boundaried = withErrorBoundary({ + Variant: MockErrorVariant, + packedBaseModule: MockBaseModule, + onVariantError: mockOnVariantError, + unpackComponent }); + render(); }); - }; - describe("when the given component has a displayName", () => { - beforeEach(() => { - MockVariant.displayName = "test-display-name"; + afterEach(() => { + console.error.mockRestore(); }); - makeCommonAssertions(MockVariant); + makeCommonAssertions(); - it("should name the HOC with the fallback component's display name wrapped in 'withErrorBoundary'", () => { - expect(Boundaried.displayName).toBe( - `withErrorBoundary(${MockVariant.displayName})` - ); + it("should get the display name of the variant passed", () => { + expect(getDisplayName).toHaveBeenCalledWith(MockErrorVariant); }); - }); - describe("when the given component does not have a displayName but is a named function", () => { - beforeEach(() => { - delete MockVariant.displayName; - MockVariant.name = "test-name"; + it("should unpack the fallback component", () => { + expect(unpackComponent).toHaveBeenCalledWith(MockBaseModule); }); - makeCommonAssertions(MockVariant); - - it("should name the HOC with the fallback component's name wrapped in 'withErrorBoundary'", () => { - expect(Boundaried.displayName).toBe( - `withErrorBoundary(${MockVariant.name})` - ); + it("should render the fallback, passing the inbound props, and no longer render the variant", () => { + const fallbackElement = screen.getByTestId(mockBaseModule); + expect(fallbackElement).toBeInTheDocument(); + expect(fallbackElement).toBe(mockRef.current); + expect(MockBaseModule.render).toHaveBeenCalledWith(inboundProps, mockRef); + expect(screen.queryByTestId(mockVariant)).not.toBeInTheDocument(); }); - }); - describe("when the given component does not have a displayName or a function name", () => { - beforeEach(() => { - delete MockVariant.displayName; - delete MockVariant.name; + it("should call the onVariantError callback, indicating that the fallback has been rendered", () => { + expect(mockOnVariantError).toHaveBeenCalledWith(mockError); }); - makeCommonAssertions(MockVariant); - - it("should name the HOC with 'Component' wrapped in 'withErrorBoundary'", () => { - expect(Boundaried.displayName).toBe("withErrorBoundary(Component)"); + it("should update the message on the error to include that the variant has errored", () => { + expect(mockError.message).toBe( + `Variant errored, rendering fallback: ${mockErrorMessage}` + ); }); }); }); diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getDisplayName.js b/packages/react-pointcuts/src/withTogglePointFactory/getDisplayName.js new file mode 100644 index 0000000..10c29dc --- /dev/null +++ b/packages/react-pointcuts/src/withTogglePointFactory/getDisplayName.js @@ -0,0 +1,5 @@ +const getDisplayName = (WrappedComponent) => { + return WrappedComponent.displayName || WrappedComponent.name || "Component"; +}; + +export default getDisplayName; diff --git a/packages/react-pointcuts/src/withTogglePointFactory/getDisplayName.test.js b/packages/react-pointcuts/src/withTogglePointFactory/getDisplayName.test.js new file mode 100644 index 0000000..ba244ba --- /dev/null +++ b/packages/react-pointcuts/src/withTogglePointFactory/getDisplayName.test.js @@ -0,0 +1,40 @@ +import getDisplayName from "./getDisplayName"; + +describe("getDisplayName", () => { + let result; + + describe("when the given component has a displayName", () => { + const mockComponent = () => {}; + mockComponent.displayName = "test-component"; + + beforeEach(() => { + result = getDisplayName(mockComponent); + }); + + it("should return the component's display name", () => { + expect(result).toBe(mockComponent.displayName); + }); + }); + + describe("when the given component does not have a displayName, but has a function name", () => { + const mockComponent = () => {}; + + beforeEach(() => { + result = getDisplayName(mockComponent); + }); + + it("should return the function name that constructed the component", () => { + expect(result).toBe("mockComponent"); + }); + }); + + describe("when the given component does not have a displayName or a function name", () => { + beforeEach(() => { + result = getDisplayName(() => {}); + }); + + it("should return 'Component'", () => { + expect(result).toBe("Component"); + }); + }); +}); diff --git a/packages/react-pointcuts/src/withTogglePointFactory/index.js b/packages/react-pointcuts/src/withTogglePointFactory/index.js index a30e583..344ca23 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/index.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/index.js @@ -1,7 +1,25 @@ -import { useMemo, forwardRef } from "react"; +import { useMemo, forwardRef, Suspense, Fragment } from "react"; import getComponent from "./getComponent"; import useCodeMatches from "../useCodeMatches"; import getHooksFromPlugins from "../getHooksFromPlugins"; +import { useDeferredValue } from "./useDeferredValueWhereAvailable"; +import getDisplayName from "./getDisplayName"; +import { isLazy, isValidElementType } from "react-is"; + +const isModuleLazyComponent = (Module) => + isValidElementType(Module) && isLazy(); + +/** + * @typedef {external.ReactComponentModuleNamespaceObject | function() : external.ReactComponentModuleNamespaceObject | function() : Promise} LoadWrappedReactComponentModule + * @memberof web-toggle-point-react-pointcuts + * @see {@link https://reactjs.org/docs/react-component.html|React.Component} + */ + +/** + * @typedef {external.ReactHookModuleNamespaceObject | function() : external.ReactHookModuleNamespaceObject | function() : Promise} LoadWrappedReactHookModule + * @memberof web-toggle-point-react-pointcuts + * @see {@link https://reactjs.org/docs/hooks-overview.html|React.Hook} + */ // eslint-disable-next-line no-empty -- https://github.com/babel/babel/issues/15156 { @@ -13,9 +31,10 @@ import getHooksFromPlugins from "../getHooksFromPlugins"; * @function * @param {object} params parameters * @param {function} params.getActiveFeatures a method to get active features. Called honouring the rules of hooks. + * @param {external:HostApplication.logError} params.logError a method that logs errors * @param {string} [params.variantKey='bucket'] A key use to identify a variant from the features data structure. Remaining members of the feature will be passed to the variant as props. - * @param {Array} [params.plugins] plugins to be used when toggling. - * Any plugins that include a 'onVariantError' hook will be called when a toggled component throws an error that can be caught by an {@link https://reactjs.org/docs/error-boundaries.html|ErrorBoundary}. + * @param {Array} [params.plugins] plugins to be used when toggling + * Will be used when a toggled component throws an error that can be caught by an {@link https://reactjs.org/docs/error-boundaries.html|ErrorBoundary}. * When errors are caught, the control/base code will be used as the fallback component. * @returns {module:web-toggle-point-react-pointcuts.withTogglePoint} withTogglePoint React Higher-Order-Component. * @example @@ -42,16 +61,30 @@ const withTogglePointFactory = ({ const variantErrorPlugins = getHooksFromPlugins(plugins, "onVariantError"); /** - * A React Higher-Order-Component that wraps a base / control component and swaps in a variant when deemed appropriate by a context + * A React Higher-Order-Component that wraps a base / control component and swaps in a variant based on the active features supplied * @function withTogglePoint * @memberof module:web-toggle-point-react-pointcuts - * @param {ReactComponentModuleNamespaceObject} controlModule The control / base module - * @param {external:React.Component} controlModule.default The control react component - * @param {Map} featuresMap A map of features and their variants, with features as top-level keys and variants as nested keys with modules as the values. + * @param {web-toggle-point-react-pointcuts.LoadWrappedReactComponentModule} joinPoint The joinPoint module, packed in a form defined by the loading strategy + * @param {Map>} featuresMap A map of features and their variants, with features as top-level keys and variants as nested keys with react component modules as the values, packed in a form defined by the loading strategy + * @param {function} unpack a function that unpacks the module, returning a react component module * @returns {external:React.Component} Wrapped react component */ - const withTogglePoint = (controlModule, featuresMap) => { - const { default: control } = controlModule; + const withTogglePoint = ({ + joinPoint: packedBaseModule, + featuresMap, + unpack + }) => { + const isLazyComponents = isModuleLazyComponent(packedBaseModule); + const Wrapper = isLazyComponents + ? ({ children }) => ( + {useDeferredValue(children)} + ) + : Fragment; + const unpackComponent = (packedModule) => { + const unpacked = unpack(packedModule); + return isLazyComponents ? unpacked : unpacked.default; + }; + const WithTogglePoint = forwardRef((props, ref) => { const activeFeatures = getActiveFeatures(); const { matchedFeatures, matchedVariant } = useCodeMatches({ @@ -65,19 +98,22 @@ const withTogglePointFactory = ({ getComponent({ matchedFeatures, matchedVariant, - control, + packedBaseModule, + unpackComponent, codeSelectionPlugins, variantErrorPlugins }), [matchedFeatures, matchedVariant] ); - return ; + return ( + + + + ); }); - WithTogglePoint.displayName = `withTogglePoint(${ - control.displayName || control.name || "Component" - })`; + WithTogglePoint.displayName = `withTogglePoint(${getDisplayName(packedBaseModule)})`; return WithTogglePoint; }; diff --git a/packages/react-pointcuts/src/withTogglePointFactory/index.test.js b/packages/react-pointcuts/src/withTogglePointFactory/index.test.js index 5d26cc3..07f1a8b 100644 --- a/packages/react-pointcuts/src/withTogglePointFactory/index.test.js +++ b/packages/react-pointcuts/src/withTogglePointFactory/index.test.js @@ -3,7 +3,8 @@ import { render, screen } from "@testing-library/react"; import useCodeMatches from "../useCodeMatches"; import getComponent from "./getComponent"; import getHooksFromPlugins from "../getHooksFromPlugins"; -import { createRef, forwardRef } from "react"; +import { createRef, forwardRef, lazy } from "react"; +import getDisplayName from "./getDisplayName"; const mockMatches = {}; jest.mock("../useCodeMatches", () => jest.fn(() => mockMatches)); @@ -24,6 +25,8 @@ const MockVariedComponent = forwardRef( ); jest.mock("./getComponent", () => jest.fn(() => MockVariedComponent)); +const mockDisplayName = "test-display-name"; +jest.mock("./getDisplayName", () => jest.fn(() => mockDisplayName)); describe("withTogglePointFactory", () => { let rerender; @@ -32,27 +35,29 @@ describe("withTogglePointFactory", () => { const mockPlugins = [Symbol("test-plugin1"), Symbol("test-plugin2")]; const mockActiveFeatures = Symbol("test-active-features"); const getActiveFeatures = jest.fn(() => mockActiveFeatures); + const mockComponentModule = { default: () =>
test-component
}; - let Toggled, variantKey; + let Toggled, createMockVariant; describe.each` inputVariantKey | expectedVariantKey - ${variantKey} | ${"bucket"} + ${undefined} | ${"bucket"} ${"test-key"} | ${"test-key"} `( "when given a variant key of $inputVariantKey", ({ inputVariantKey, expectedVariantKey }) => { - const makeCommonAssertions = (mockComponent) => { + let unpack; + + const makeCommonAssertions = ({ joinPoint }) => { beforeEach(() => { mockMatches.matchedFeatures = Symbol("test-features"); - mockMatches.matchedVariant = Symbol("test-variant"); jest.clearAllMocks(); const withTogglePoint = withTogglePointFactory({ getActiveFeatures, variantKey: inputVariantKey, plugins: mockPlugins }); - Toggled = withTogglePoint({ default: mockComponent }, featuresMap); + Toggled = withTogglePoint({ joinPoint, featuresMap, unpack }); }); it("should get code selection plugins", () => { @@ -70,15 +75,6 @@ describe("withTogglePointFactory", () => { }); const makeRenderedAssertions = () => { - it("should render the varied component, passing the inbound props provided to the HOC", () => { - expect(screen.getByTestId(mockVariedComponent)).toBeInTheDocument(); - const ref = expect.toBeOneOf([null, expect.anything()]); - expect(MockVariedComponent.render).toHaveBeenCalledWith( - inboundProps, - ref - ); - }); - it("should check for code matches, based on the result of the getActiveFeatures method passed and the potential code paths on disk", () => { expect(useCodeMatches).toHaveBeenCalledWith({ featuresMap, @@ -86,6 +82,15 @@ describe("withTogglePointFactory", () => { activeFeatures: mockActiveFeatures }); }); + + it("should render the (potentially) varied component, passing the inbound props provided to the HOC", () => { + expect(screen.getByTestId(mockVariedComponent)).toBeInTheDocument(); + const ref = expect.toBeOneOf([null, expect.anything()]); + expect(MockVariedComponent.render).toHaveBeenCalledWith( + inboundProps, + ref + ); + }); }; const makeGetComponentAssertions = () => { @@ -94,17 +99,36 @@ describe("withTogglePointFactory", () => { expect(getComponent).toHaveBeenCalledWith({ matchedFeatures, matchedVariant, - control: mockComponent, + packedBaseModule: joinPoint, + unpackComponent: expect.any(Function), codeSelectionPlugins: mockCodeSelectionPlugins, variantErrorPlugins: mockVariantErrorPlugins }); }); + + describe("When the get component function uses the unpackComponent method passed", () => { + beforeEach(() => { + const { unpackComponent } = getComponent.mock.calls[0][0]; + unpackComponent(mockMatches.matchedVariant); + }); + + it("should unpack the module", () => { + expect(unpack).toHaveBeenCalledWith(mockMatches.matchedVariant); + }); + }); }; const makeCommonAssertions = () => { makeRenderedAssertions(); makeGetComponentAssertions(); + it("should prepare a display name based on the display name of the joinPoint", () => { + expect(getDisplayName).toHaveBeenCalledWith(joinPoint); + expect(Toggled.displayName).toBe( + `withTogglePoint(${mockDisplayName})` + ); + }); + describe("when the component re-renders", () => { beforeEach(() => { getComponent.mockClear(); @@ -117,7 +141,7 @@ describe("withTogglePointFactory", () => { describe("and the matched variant has updated", () => { beforeEach(() => { - mockMatches.matchedVariant = Symbol("test-new-variant"); + mockMatches.matchedVariant = createMockVariant(); rerender(); }); @@ -176,34 +200,28 @@ describe("withTogglePointFactory", () => { }); }; - describe("when the given fallback component has a displayName", () => { - const mockComponent = () => {}; - mockComponent.displayName = "test-component"; - - makeCommonAssertions(mockComponent); - - it("should name the HOC with the fallback component's display name wrapped in 'withTogglePoint'", () => { - expect(Toggled.displayName).toBe( - `withTogglePoint(${mockComponent.displayName})` - ); + describe("when the given joinPoint is a lazy component", () => { + beforeEach(() => { + createMockVariant = () => + lazy(Promise.resolve(Symbol("test-variant"))); + unpack = jest.fn((module) => module); + mockMatches.matchedVariant = createMockVariant(); }); - }); - describe("when the given fallback component does not have a displayName, but has a function name", () => { - const mockComponent = () => {}; - - makeCommonAssertions(mockComponent); - - it("should name the HOC with the fallback component's name wrapped in 'withTogglePoint'", () => { - expect(Toggled.displayName).toBe(`withTogglePoint(mockComponent)`); + makeCommonAssertions({ + joinPoint: lazy(Promise.resolve(mockComponentModule)) }); }); - describe("when the given fallback component does not have a displayName or a function name", () => { - makeCommonAssertions(() => {}); + describe("when the given joinPoint is not a lazy component", () => { + beforeEach(() => { + createMockVariant = () => ({ default: Symbol("test-variant") }); + unpack = jest.fn((module) => module.default); + mockMatches.matchedVariant = createMockVariant(); + }); - it("should name the HOC with 'Component' wrapped in 'withTogglePoint'", () => { - expect(Toggled.displayName).toBe(`withTogglePoint(Component)`); + makeCommonAssertions({ + joinPoint: mockComponentModule }); }); } diff --git a/packages/react-pointcuts/src/withTogglePointFactory/useDeferredValueWhereAvailable.js b/packages/react-pointcuts/src/withTogglePointFactory/useDeferredValueWhereAvailable.js new file mode 100644 index 0000000..491c522 --- /dev/null +++ b/packages/react-pointcuts/src/withTogglePointFactory/useDeferredValueWhereAvailable.js @@ -0,0 +1,10 @@ +let useDeferredValue = (value) => value; + +(async () => { + const React = await import("react"); + if (React.useDeferredValue) { + useDeferredValue = React.useDeferredValue; + } +})(); + +export { useDeferredValue }; diff --git a/packages/react-pointcuts/src/withTogglePointFactory/useDeferredValueWhereAvailable.test.js b/packages/react-pointcuts/src/withTogglePointFactory/useDeferredValueWhereAvailable.test.js new file mode 100644 index 0000000..ef680e2 --- /dev/null +++ b/packages/react-pointcuts/src/withTogglePointFactory/useDeferredValueWhereAvailable.test.js @@ -0,0 +1,37 @@ +const mockReact = {}; +jest.isolateModules(() => { + jest.mock("react", () => mockReact); +}); + +describe("useDeferredValueWhereAvailable", () => { + let useDeferredValue; + + beforeEach(async () => { + jest.resetModules(); + }); + + describe("when the feature is not available", () => { + it("should not error, and return an identity function", async () => { + ({ useDeferredValue } = await import("./useDeferredValueWhereAvailable")); + expect(useDeferredValue).toBeInstanceOf(Function); + + const test = Symbol("test-value"); + expect(useDeferredValue(test)).toBe(test); + }); + }); + + describe("when the feature is available", () => { + const mockUseDeferredValue = Symbol("test-useDeferredValue"); + + beforeEach(async () => { + mockReact.useDeferredValue = mockUseDeferredValue; + await Promise.resolve(); + ({ useDeferredValue } = await import("./useDeferredValueWhereAvailable")); // TODO: understand need for double import, why not await Promise.resolve(), process.nextTick() etc. this is a live binding... + ({ useDeferredValue } = await import("./useDeferredValueWhereAvailable")); + }); + + it("should return the feature from React", () => { + expect(useDeferredValue).toBe(mockUseDeferredValue); + }); + }); +}); diff --git a/packages/react-pointcuts/src/withToggledHookFactory/index.js b/packages/react-pointcuts/src/withToggledHookFactory/index.js index 0900441..9ef8dbb 100644 --- a/packages/react-pointcuts/src/withToggledHookFactory/index.js +++ b/packages/react-pointcuts/src/withToggledHookFactory/index.js @@ -35,12 +35,15 @@ const withToggledHookFactory = ({ * A React hook that wraps a base / control function or hook and swaps in a variant based on the active features supplied * @function withToggledHook * @memberof module:web-toggle-point-react-pointcuts - * @param {ReactHookModuleNamespaceObject} controlModule The control / base module - * @param {(external:React.Hook|function)} controlModule.default The control react hook or function. - * @param {Map>} featuresMap A map of features and their variants, with features as top-level keys and variants as nested keys with modules as the values. + * @param {LoadWrappedReactHookModule} controlModule The control / base module + * @param {Map>} featuresMap A Map of features and their variants, with features as top-level keys and variants as nested keys with loader-wrapped react hook modules as the values * @returns {external:React.Hook} Wrapped function / hook, as a hook (so must be applied in accordance with the {@link https://reactjs.org/docs/hooks-rules.html|rules of hooks}) */ - const withToggledHook = (controlModule, featuresMap) => { + const withToggledHook = ({ + joinPoint: packedBaseModule, + featuresMap, + unpack + }) => { const useTogglePoint = (...args) => { const activeFeatures = getActiveFeatures(); const { matchedVariant } = useCodeMatches({ @@ -51,7 +54,9 @@ const withToggledHookFactory = ({ useCodeSelectionPlugins?.(...args); - const { default: hook } = matchedVariant?.codeRequest ?? controlModule; + const { default: hook } = unpack( + matchedVariant?.packedModule ?? packedBaseModule + ); return hook(...args); }; diff --git a/packages/react-pointcuts/src/withToggledHookFactory/index.test.js b/packages/react-pointcuts/src/withToggledHookFactory/index.test.js index 8aa8451..d334437 100644 --- a/packages/react-pointcuts/src/withToggledHookFactory/index.test.js +++ b/packages/react-pointcuts/src/withToggledHookFactory/index.test.js @@ -20,7 +20,9 @@ describe("withToggledHookFactory", () => { const mockPlugins = [Symbol("test-plugin1"), Symbol("test-plugin2")]; const initialProps = Symbol("test-arg"); const mockActiveFeatures = Symbol("test-active-features"); + const getActiveFeatures = jest.fn(() => mockActiveFeatures); + const unpack = jest.fn((module) => module); beforeEach(() => { jest.clearAllMocks(); @@ -62,7 +64,7 @@ describe("withToggledHookFactory", () => { beforeEach(() => { mockMatches.matchedVariant = { - codeRequest: { + packedModule: { default: variant } }; @@ -71,6 +73,12 @@ describe("withToggledHookFactory", () => { })); }); + it("should unpack the variant module", () => { + expect(unpack).toHaveBeenCalledWith( + mockMatches.matchedVariant.packedModule + ); + }); + it("should call and return the output of the matched variant", () => { expect(variant).toHaveBeenCalledWith(initialProps); expect(result.current).toBe(output); @@ -92,6 +100,12 @@ describe("withToggledHookFactory", () => { ({ result } = renderHook(toggledHook, { initialProps })); }); + it("should unpack the control module", () => { + expect(unpack).toHaveBeenCalledWith({ + default: mockControlHook + }); + }); + it("should call and return the output of the fallback (control) hook", () => { expect(mockControlHook).toHaveBeenCalledWith(initialProps); expect(result.current).toBe(output); @@ -106,7 +120,7 @@ describe("withToggledHookFactory", () => { beforeEach(() => { mockMatches.matchedVariant = { - codeRequest: { + packedModule: { default: jest.fn() } }; @@ -139,7 +153,11 @@ describe("withToggledHookFactory", () => { variantKey, plugins: mockPlugins }); - toggledHook = withToggledHook({ default: mockControlHook }, featuresMap); + toggledHook = withToggledHook({ + joinPoint: { default: mockControlHook }, + featuresMap, + unpack + }); }); makeCommonAssertions(); @@ -154,7 +172,11 @@ describe("withToggledHookFactory", () => { variantKey, plugins: mockPlugins }); - toggledHook = withToggledHook({ default: mockControlHook }, featuresMap); + toggledHook = withToggledHook({ + joinPoint: { default: mockControlHook }, + featuresMap, + unpack + }); }); makeCommonAssertions(); diff --git a/packages/webpack/babel.jest.json b/packages/webpack/babel.jest.json new file mode 100644 index 0000000..984bae4 --- /dev/null +++ b/packages/webpack/babel.jest.json @@ -0,0 +1,12 @@ +{ + "plugins": [ + [ + "babel-plugin-transform-import-meta-x", + { + "replacements": { + "filename": "__filename" + } + } + ] + ] +} diff --git a/packages/webpack/build/rollup.mjs b/packages/webpack/build/rollup.mjs index d85748c..596f9d8 100644 --- a/packages/webpack/build/rollup.mjs +++ b/packages/webpack/build/rollup.mjs @@ -1,33 +1,30 @@ -import pkg from "../package.json" with { type: "json" }; import babel from "@rollup/plugin-babel"; import resolve from "@rollup/plugin-node-resolve"; import external from "rollup-plugin-auto-external"; import commonjs from "@rollup/plugin-commonjs"; import keepExternalComments from "./keepExternalComments.mjs"; -import copy from "rollup-plugin-copy"; import json from "@rollup/plugin-json"; export default { - input: "./src/index.js", - output: [ - { - file: pkg.exports["./plugins"].require, - format: "cjs", - sourcemap: true - }, - { - file: pkg.exports["./plugins"].import, - format: "es", - sourcemap: true - } - ], + input: { + plugins: "./src/plugins/index.js", + "toggleHandlerFactories/pathSegment": + "src/toggleHandlerFactories/pathSegment.js", + "moduleLoadStrategyFactories/staticLoadStrategyFactory": + "src/moduleLoadStrategyFactories/staticLoadStrategyFactory.js", + "moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory": + "src/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.js", + "moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory": + "src/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory.js" + }, + output: { + dir: "lib/", + entryFileNames: "[name].js", + format: "es", + sourcemap: true + }, plugins: [ keepExternalComments, - copy({ - targets: [ - { src: ["./src/toggleHandlers/*", "!**/*.test.*"], dest: "lib" } - ] - }), babel({ exclude: [/node_modules/], babelHelpers: "runtime" diff --git a/packages/webpack/docs/CHANGELOG.md b/packages/webpack/docs/CHANGELOG.md index 4675b45..a846bcf 100644 --- a/packages/webpack/docs/CHANGELOG.md +++ b/packages/webpack/docs/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - ????-??-?? + +### Added + +- "load strategy" concept + - provide `moduleLoadStrategyFactories` exports: `staticLoadStrategyFactory`, `deferredRequireLoadStrategyFactory`, `deferredDynamicImportLoadStrategyFactory` + +### Changed + +- changed description of package to better indicate it contains more than just a plugin +- renamed `TogglePointInjection` to `TogglePointInjectionPlugin` to follow Webpack recommended naming convention +- renamed `togglePointModule` to `togglePointModuleSpecifier` to better describe [the type](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script) +- updated webpack version to 5.99.7 +- various internal variable renames for clarity +- wrapped "toggle handlers" in a factory method, to support `togglePoint`, `pack` and `unpack` being passed during setup, simplifying the toggle handler interface + - move `toggleHandler/pathSegmentToggleHandler` to `toggleHandlerFactories/pathSegment` +- update [`Webpack`](https://webpack.js.org/) version 5.104.1 + - update comment in `generatePointCut.test.js` to indicate that despite raised issue being fixed, NextJS is stuck on Webpack [`5.98.0` ](https://github.com/webpack/webpack/tree/v5.98.0) from [13th Feb](https://github.com/webpack/webpack/releases/tag/v5.98.0) + ## [0.9.2] - 2025-11-14 ### Fixed diff --git a/packages/webpack/docs/README.md b/packages/webpack/docs/README.md index 1a75e68..8542819 100644 --- a/packages/webpack/docs/README.md +++ b/packages/webpack/docs/README.md @@ -1,6 +1,6 @@ # @asos/web-toggle-point-webpack -This package provides a [webpack plugin](https://webpack.js.org/concepts/plugins/) that's designed to identify [join points](https://en.wikipedia.org/wiki/Join_point) during a build process based on a configurable filesystem convention, then re-direct requests to those modules to a proxy. The proxy makes a decision at runtime, based on some context, to either enact the original module, or a variant. +This package provides a [webpack plugin](https://webpack.js.org/concepts/plugins/) that's designed to identify [join points](https://en.wikipedia.org/wiki/Join_point) during a build process based on a configurable filesystem convention, then re-direct requests to those modules to a proxy. The proxy makes a decision at runtime, based on something contextual or otherwise variable, to either enact the original module, or a variant. The join points are configured as a [pointcut](https://en.wikipedia.org/wiki/Pointcut) which defines the convention to identify the join points within the input file system, and the toggle handler that should enact decisions at runtime, choosing the appropriate module to run. @@ -34,12 +34,18 @@ The plugin constructor takes `TogglePointInjectionOptions` thus: ```typescript import webpack from 'webpack'; +interface LoadStrategy { + adapterModuleSpecifier: string, + importCodeGenerator: ({ joinPointPath: string, variantPathMap: Map }) => string +} + interface PointCut { name: string; - togglePointModule: string; + togglePointModuleSpecifier: string; variantGlobs?: string[]; joinPointResolver?: (variantPath: string) => string; - toggleHandler?: string; + toggleHandlerFactoryModuleSpecifier: string; + loadStrategy: LoadStrategy } interface TogglePointInjectionOptions { @@ -53,22 +59,22 @@ interface TogglePointInjectionOptions { > [!IMPORTANT] > N.B. when setting up multiple pointcuts, the path matched by the [globs](https://en.wikipedia.org/wiki/Glob_(programming)) must be mutually exclusive. Otherwise, the pointcut defined earlier in the array "wins", and a warning is emitted into the compilation indicating that the subsequent cuts are neutered for those matching files. > -> Also, due to the way Webpack works, there should only be a single `TogglePointInjection` plugin per webpack configuration, so utilize the point cuts array, rather than having separate plugin instances per point cut. +> Also, due to the way Webpack works, there should only be a single `TogglePointInjectionPlugin` plugin per webpack configuration, so utilize the point cuts array, rather than having separate plugin instances per point cut. #### _`name`_ -Each toggling concern should be expressed in the configured `name` of the `PointCut`. This name appears in logs and in dev tools for browser code, but otherwise can be anything. +Each toggling concern should be expressed in the configured `name` of the `PointCut`. This name appears in logs and in dev tools for browser code, but otherwise does not affect functionality. -#### _`togglePointModule`_ +#### _`togglePointModuleSpecifier`_ -The module definition should be resolvable by webpack, either as a path within the codebase, or a module accessible from `node_modules` using webpack module resolution. +A [module specifier](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script) pointing to code that holds a "toggle point", the place in code where module selection decisions are made at runtime. This should be resolvable by webpack. It's paramount that this module is compatible with the modules it is varying. e.g. - a toggle point that utilises browser APIs as part of making its choice or enacting side-effects should only target browser code. - a toggle point that utilises [React](https://react.dev/) can only toggle react code, since use of such APIs outside of the react runtime would produce a runtime error. -Also, the interface of the toggle point, and the variations that it may supplant, needs to be interchangeable with the base/default module. [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle) must apply; functionality may differ, but it must still be compatible with all the consumers of the base module. +Also, the interface of the toggle point, and the variations that it may supplant, needs to be interchangeable with the base/default module. [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle) must apply. Functionality may differ, but it must still be compatible with all the consumers of the base module. #### _`variantGlobs`_ @@ -78,9 +84,9 @@ These can be as specific or generic as needed, but ideally the most specific pos The common case is a single glob, but an array is provided to mitigate the need for complex ["brace expansion"](https://github.com/micromatch/braces) or [extended globs](https://github.com/micromatch/micromatch#extglobs), which could be complex when targeting variations which are located in disparate parts of a codebase. -It should match modules that are compatible with the `togglePointModule` - e.g. if all React code is held within a `/components` folder, it makes sense to include this in the glob path to avoid inadvertently toggling non-react code (should a variant be set up for non-React code without considering the configuration). +It should match modules that are compatible with the `togglePointModuleSpecifier` - e.g. if all React code is held within a `/components` folder, it makes sense to include this in the glob path to avoid inadvertently toggling non-react code (should a variant be set up for non-React code without considering the configuration). -This glob holds the key for the naming convention approach that underpins the toggle point project, since it is the definition of the join point triggers. +This glob is a key part of the naming convention approach that underpins the toggle point project, since it defines the join point triggers. If not supplied, a default `glob` of `/**/__variants__/*/*/!(*.test).{js,jsx,ts,tsx}` is used. @@ -122,10 +128,42 @@ e.g. a variant at `./__variants__/feature-name/variant-name/module.js` will reso > [!TIP] > An "escape hatch" exists to prevent untoward convention-based variation, via creation of `__toggleConfig.json` files, sitting in the same folder as potential join points / base modules. This can contain a `joinPoints` array, listing the filenames in the directory that should be considered eligible. Any empty array disables all toggling for that folder. e.g. > ```json ->{ -> "joinPoints": ["validJoinPoint.js", "anotherValidJoinPoint.js"] ->} ->``` +> { +> "joinPoints": ["validJoinPoint.js", "anotherValidJoinPoint.js"] +> } +> ``` + +#### _`toggleHandlerFactoryModuleSpecifier`_ + +A [module specifier](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script) pointing to code that holds a "toggle handler factory". + +This is another key part of the filesystem naming convention, in that it takes absolute paths from the filesystem and converts them into a data structure appropriate for the potential features that can be selected. That data structure is passed to the "toggle point" (as defined by the `togglePointModuleSpecifier`) as a "featuresMap". + +This factory receives a `Map` keyed by file paths relative to the base/control module, valued by module accessing code, as defined by the `loadStrategy`. These should be processed into a form suitable for the toggle point, assumed some kind of `Map` that can be interrogated based on some feature toggle state. + +If not supplied, the default handler (`@asos/web-toggle-point-webpack/toggleHandlerFactories/pathSegment`) is used, compatible with the default `variantGlob`. + +This converts path segments to features and variant names, storing in a tree data structure held in a `Map`, with each path segment as a node in the tree, and the variant modules as the leaf nodes. + +#### _`loadStrategy`_ + +This should be an object containing two properties: + +- `importCodeGenerator` + +This should be a function, used at compiled time, that generates javascript suitable to load modules, outputting a `jointPoint` and a `variantPathMap`. The code it returns is used when creating join point proxy modules. + +The generator receives two arguments, `joinPointPath` (representing the full path of the join point) and `variantPathMap` (a `Map` keyed by relative paths to variant code, in relation to the control/base module, valued by the absolute path of the variant code). + +It generate code that leaves a `joinPoint` and `variantPathMap` variable in scope; the proxy module code will pass these to a toggle point. + +- `adapterModuleSpecifier` + +A [module specifier](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script) that should optionally hold `pack` and `unpack` named exports. + +These are made available to the `toggleHandlerFactory` so that it can mutate the require/import expressions generated by the `importCodeGenerator` before storing in the "featuresMap" it generates. The `unpack` method is made available to the "toggle point" to access a module for immediate use. This scheme allows for deferred / lazy execution, or otherwise. + +The module specifier will usually be the file path of the `loadStrategy` itself, since the code generated is coupled to the means of packing and unpacking. The `pack` and `unpack` methods are compiled into the build, so used at runtime. #### Other configuration @@ -165,10 +203,10 @@ Given the following file structure in the repo: ...and the following configuration to the plugin: ```javascript -const plugin = new TogglePointInjection({ +const plugin = new TogglePointInjectionPlugin({ pointCuts: [{ name: "my point cut", - togglePointModule: "/src/modules/withTogglePoint", + togglePointModuleSpecifier: "/src/modules/withTogglePoint", variantGlobs: ["./src/modules/**/__variants__/*/*/*.js"] }] }); @@ -180,14 +218,20 @@ That proxy module will, in turn, import a module with id `toggle:/point-cuts:/my The `toggle:/point-cuts:/my point cut` then imports the configured toggle point (and toggle handler, if configured), then calls the handler with the toggle point, join point module, and variants. The handler is expected to convert the key/value `Map` of variants into a data structure appropriate for the toggle point (the default being a `Map` keyed by feature, then variant, to variant module). -This toggle point is then expected to return the outcome, having chosen the appropriate module at runtime. +It constructs a toggle handler, having passed it the toggle point and the `pack` and `unpack` methods of the load strategy (or [https://en.wikipedia.org/wiki/Identity_function](identity functions), if not configured). + +The handler is expected to construct a `featuresMap` data structure, based on the paths of the variation modules received. The filesystem should define the feature toggle state which each module is relevant for, and this map should provide the means to look those modules up based on that state. The modules should be placed in the structure in a "packed" form, as decreed by the `pack` method of the load strategy. + +The handler should then call the toggle point, with this `featuresMap` data structure, along with the `unpack` method, to allow the toggle point to prepare a module for immediate use, should it be chosen at run-time. + +This toggle point is then expected to return the chosen module. ```javascript -const togglePoint = (joinPoint, featuresMap) => { +const togglePoint = ({ joinPoint, featuresMap, unpack }) => { if (feature2Variant1ShouldApply()) { // some bespoke logic held in the toggle point for this type of toggle - return featuresMap.get("feature2").get("variant1").default; + return unpack(featuresMap.get("feature-identifier").get("variant-identifier")); }; - return joinPoint.default; + return unpack(joinPoint); } ``` @@ -200,39 +244,51 @@ sequenceDiagram participant issuer as Issuer Module participant joinPoint as toggle:/join-point:/src/modulePath participant pointCut as toggle:/point-cut:/experiments - participant handler as Toggle Handler + participant handlerFactory as Toggle Handler Factory participant togglePoint as Toggle Point + participant loadStrategy as Load Strategy participant default as /src/modulePath participant variant1 as /src/variant1/modulePath participant variant2 as /src/variant2/modulePath issuer ->> joinPoint: import - joinPoint ->> default: import + joinPoint ->> default: import() / require() default -->> joinPoint: joinPoint joinPoint ->> pointCut: import - pointCut ->> handler: import - pointCut ->> togglePoint: import + pointCut ->> loadStrategy: import { pack, unpack } + loadStrategy -->> pointCut: { pack, unpack } par - joinPoint ->> variant1: require.context + joinPoint ->> variant1: import() / require() variant1 -->> joinPoint: variant1 and - joinPoint ->> variant2: require.context + joinPoint ->> variant2: import() / require() variant2 -->> joinPoint: variant2 end issuer ->> joinPoint: default(args) - joinPoint ->> pointCut: default({ joinPoint, variants: [variant1, variant2] }) - pointCut ->> handler: default({ togglePoint, joinPoint, variants }) - handler ->> handler: construct Map from variants - handler ->> togglePoint: default(joinPoint, featuresMap) + joinPoint -> joinPoint: variantPathMap = Map([
["./variant1/modulePath", variant1],
["./variant1/modulePath", variant2]
]) + joinPoint ->> pointCut: default({ joinPoint, variantPathMap }) + pointCut ->> handlerFactory: create({ togglePoint, pack, unpack }) + create participant handler as Toggle Handler + handlerFactory ->> handler: create + handler -->> handlerFactory: handler + handlerFactory -->> pointCut: handler + pointCut -->> joinPoint: handler + joinPoint ->> handler: default({ joinPoint, variantPathMap }) + handler ->> handler: construct features Map from variantPathMap (packed) + handler ->> handler: packedJoinPoint: pack(joinPoint) + handler ->> togglePoint: default({ joinPoint: packedJoinPoint, featuresMap, unpack }) alt base/default is relevant, via toggle point logic + togglePoint ->> togglePoint: unpack(default) togglePoint ->> default: default(args) default -->> togglePoint: result togglePoint -->> issuer: result else variant1 is relevant, via toggle point logic + togglePoint ->> togglePoint: unpack(variant1) togglePoint ->> variant1: default(args) variant1 -->> togglePoint: result togglePoint -->> issuer: result else variant2 is relevant, via toggle point logic + togglePoint ->> togglePoint: unpack(variant2) togglePoint ->> variant2: default(args) variant2 -->> togglePoint: result togglePoint -->> issuer: result diff --git a/packages/webpack/jest.config.json b/packages/webpack/jest.config.json new file mode 100644 index 0000000..7be9d1e --- /dev/null +++ b/packages/webpack/jest.config.json @@ -0,0 +1,5 @@ +{ + "transform": { + "\\.js$": ["babel-jest", { "configFile": "./babel.jest.json" }] + } +} diff --git a/packages/webpack/package.json b/packages/webpack/package.json index bd33b24..c38111d 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -4,13 +4,14 @@ "version": "0.9.2", "license": "MIT", "type": "module", - "main": "./lib/main.cjs", + "engines": { + "node": ">=20.11.0" + }, "exports": { - "./plugins": { - "import": "./lib/main.js", - "require": "./lib/main.cjs" - }, - "./pathSegmentToggleHandler": "./lib/pathSegmentToggleHandler.js" + ".": null, + "./plugins": "./lib/plugins.js", + "./moduleLoadStrategyFactories/*": "./lib/moduleLoadStrategyFactories/*.js", + "./toggleHandlerFactories/*": "./lib/toggleHandlerFactories/*.js" }, "keywords": [ "toggle point", @@ -51,6 +52,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.1", "@types/webpack-env": "^1.18.0", + "babel-plugin-transform-import-meta-x": "^0.0.3", "eslint-plugin-jsdoc": "^50.5.0", "jest": "^29.7.0", "jsdoc": "^4.0.2", @@ -58,15 +60,14 @@ "memfs": "^4.15.0", "rimraf": "^6.0.1", "rollup-plugin-auto-external": "^2.0.0", - "rollup-plugin-copy": "^3.5.0", "schema-utils": "^4.2.0", - "webpack": "^5.88.2", + "webpack": "^5.104.1", "webpack-cli": "^4.10.0", "webpack-test-utils": "^2.1.0", "shx": "^0.4.0", "transform-markdown-links": "^2.1.0" }, "peerDependencies": { - "webpack": ">=5.70" + "webpack": ">=5.104.1" } } diff --git a/packages/webpack/src/index.js b/packages/webpack/src/index.js index a11c1e9..1f34975 100644 --- a/packages/webpack/src/index.js +++ b/packages/webpack/src/index.js @@ -4,4 +4,34 @@ import "./external.js"; * Webpack code for injecting toggle points * @module web-toggle-point-webpack */ -export { TogglePointInjection } from "./plugins"; +export { TogglePointInjectionPlugin } from "./plugins"; + +/** + * A function that generates code to load modules + * @callback importCodeGenerator + * @memberof module:web-toggle-point-webpack + * @param {object} args + * @param {string} args.joinPointPath the path to the join point module + * @param {Map} args.variantPathMap a Map of relative (to the joinPointPath) file paths to absolute file paths, for each potential variant module + * @returns {string} + * @example + * const joinPointPath = "/src/some-base-module.js"; + * const variantPathMap = new Map([ + * ["/src/variants/some-variant-1.js", "/src/variants/some-variant-1.js"], + * ["/src/variants/some-variant-2.js", "/src/variants/some-variant-2.js"], + * ]); + * importCodeGenerator({ joinPointPath, variantPathMap }); + * // output (for a contrived static load strategy compatible only with CommonJS chunk format): + * // `import * as joinPoint from "/src/some-base-module.js"; + * // const variantPathMap = new Map([ + * // ["/src/variants/some-variant-1.js", require("/src/variants/some-variant-1.js")], + * // ["/src/variants/some-variant-2.js", require("/src/variants/some-variant-2.js")], + * // ]);` + */ +/** + * A module load strategy. This is used to load the modules that contain the join point module and its variants + * @interface loadStrategy + * @memberof module:web-toggle-point-webpack + * @property {string} adapterModuleSpecifier - The {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script|module specifier} of the adapter module. This optionally exports "pack" and "unpack" functions. The module specifier is used to import the namespace into the compilation. + * @property {module:web-toggle-point-webpack.importCodeGenerator} importCodeGenerator - A function that generates the code to load the modules. + */ diff --git a/packages/webpack/src/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory.js b/packages/webpack/src/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory.js new file mode 100644 index 0000000..4aa1896 --- /dev/null +++ b/packages/webpack/src/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory.js @@ -0,0 +1,29 @@ +import dynamicLoadCodeGenerator from "./internal/dynamicLoadCodeGenerator"; + +export const unpack = (expression) => expression(); + +/** + * A load strategy factory to generate a load strategy using a deferred (only called when a module is accessed) invocation of {@link https://webpack.js.org/api/module-methods/#import-1|dynamic module import}. + * Unless an {@link https://webpack.js.org/api/module-methods/#webpackmode|"eager" webpackMode} is specified via the "webpackMagicComment" option, this will generate a separate chunk for each variant module. + * @memberOf module:web-toggle-point-webpack + * @param {object} [importCodeGeneratorFactoryOptions] options object + * @param {string} [importCodeGeneratorFactoryOptions.webpackMagicComment] An optional {@link https://webpack.js.org/api/module-methods/#magic-comments|Webpack Magic Comment}. This is a string that will be added to the import statement, and can be used to control how Webpack handles the import (e.g. prefetching, chunk names etc.). + * @memberOf module:web-toggle-point-webpack + * @returns {module:web-toggle-point-webpack.loadStrategy} + */ +const deferredDynamicImportLoadStrategyFactory = ({ + importCodeGeneratorOptions: { webpackMagicComment } = {} +} = {}) => + /** + * @implements module:web-toggle-point-webpack.loadStrategy + */ + ({ + adapterModuleSpecifier: import.meta.filename, + importCodeGenerator: dynamicLoadCodeGenerator.bind( + undefined, + "import", + webpackMagicComment + ) + }); + +export default deferredDynamicImportLoadStrategyFactory; diff --git a/packages/webpack/src/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory.test.js b/packages/webpack/src/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory.test.js new file mode 100644 index 0000000..b0412fd --- /dev/null +++ b/packages/webpack/src/moduleLoadStrategyFactories/deferredDynamicImportLoadStrategyFactory.test.js @@ -0,0 +1,67 @@ +/* eslint-disable import/namespace */ +import deferredDynamicImportLoadStrategyFactory, * as namespace from "./deferredDynamicImportLoadStrategyFactory.js"; + +const path = "/test-folder/test-path"; +const relativePaths = [ + "/test-sub-folder/test-variant-1", + "/test-sub-folder/test-variant-2", + "/test-other-sub-folder/test-variant-1" +]; +const variantPathMap = new Map( + relativePaths.map((relativePath) => [relativePath, `${path}${relativePath}`]) +); + +describe("deferredDynamicImportLoadStrategyFactory", () => { + let result; + beforeEach(() => { + result = deferredDynamicImportLoadStrategyFactory(); + }); + + it("should return an object containing the adapterModuleSpecifier, indicating the location of the factory code file (so that named exports for the pack and unpack functions can be included in the webpack compilation) and importCodeGenerator", () => { + expect(result).toEqual( + expect.objectContaining({ + adapterModuleSpecifier: expect.stringMatching( + /packages([/\\])webpack\1src\1moduleLoadStrategyFactories\1deferredDynamicImportLoadStrategyFactory\.js$/ + ), + importCodeGenerator: expect.any(Function) + }) + ); + }); + + describe("when the importCodeGenerator is called", () => { + let importCode; + + beforeEach(() => { + importCode = result.importCodeGenerator({ + joinPointPath: path, + variantPathMap + }); + }); + + it("should return a script that prepares a join point function that will dynamically import the join point, when executed", () => { + expect(importCode).toMatch(`const joinPoint = () => import("${path}");`); + }); + + it("should return a script that creates a Map of variants, keyed by relative path, valued as a function that will dynamically import the variant module when executed", () => { + expect(importCode).toMatch(`const variantPathMap = new Map([ + ["/test-sub-folder/test-variant-1", () => import("${path}${relativePaths[0]}")], + ["/test-sub-folder/test-variant-2", () => import("${path}${relativePaths[1]}")], + ["/test-other-sub-folder/test-variant-1", () => import("${path}${relativePaths[2]}")] +]);`); + }); + }); + + describe("pack", () => { + it("should not export a pack function, so that the default (identity function) is used", () => { + expect(namespace.pack).toBe(undefined); + }); + }); + + describe("unpack", () => { + it("should call the expression passed to it as a function, and return the result", () => { + const expected = Symbol("test"); + const expression = () => expected; + expect(namespace.unpack(expression)).toBe(expected); + }); + }); +}); diff --git a/packages/webpack/src/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.js b/packages/webpack/src/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.js new file mode 100644 index 0000000..3949cc7 --- /dev/null +++ b/packages/webpack/src/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.js @@ -0,0 +1,23 @@ +import dynamicLoadCodeGenerator from "./internal/dynamicLoadCodeGenerator"; + +export const unpack = (expression) => expression(); + +/** + * A load strategy factory to generate a load strategy using a deferred (only called when a module is accessed) invocation of {@link https://webpack.js.org/api/module-methods/#require|Webpack's require module method}. + * @memberOf module:web-toggle-point-webpack + * @returns {module:web-toggle-point-webpack.loadStrategy} + */ +const deferredRequireLoadStrategyFactory = () => + /** + * @implements {module:web-toggle-point-webpack.loadStrategy} + */ + ({ + adapterModuleSpecifier: import.meta.filename, + importCodeGenerator: dynamicLoadCodeGenerator.bind( + undefined, + "require", + undefined + ) + }); + +export default deferredRequireLoadStrategyFactory; diff --git a/packages/webpack/src/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.test.js b/packages/webpack/src/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.test.js new file mode 100644 index 0000000..d06ad82 --- /dev/null +++ b/packages/webpack/src/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.test.js @@ -0,0 +1,68 @@ +/* eslint-disable import/namespace */ +import deferredRequireLoadStrategyFactory, * as namespace from "./deferredRequireLoadStrategyFactory.js"; + +const path = "/test-folder/test-path"; +const relativePaths = [ + "/test-sub-folder/test-variant-1", + "/test-sub-folder/test-variant-2", + "/test-other-sub-folder/test-variant-1" +]; +const variantPathMap = new Map( + relativePaths.map((relativePath) => [relativePath, `${path}${relativePath}`]) +); + +describe("deferredRequireLoadStrategyFactory", () => { + let result; + + beforeEach(() => { + result = deferredRequireLoadStrategyFactory(); + }); + + it("should return an object containing the adapterModuleSpecifier, indicating the location of the factory code file (so that named exports for the pack and unpack functions can be included in the webpack compilation) and importCodeGenerator", () => { + expect(result).toEqual( + expect.objectContaining({ + adapterModuleSpecifier: expect.stringMatching( + /packages([/\\])webpack\1src\1moduleLoadStrategyFactories\1deferredRequireLoadStrategyFactory\.js$/ + ), + importCodeGenerator: expect.any(Function) + }) + ); + }); + + describe("when the importCodeGenerator is called", () => { + let importCode; + + beforeEach(() => { + importCode = result.importCodeGenerator({ + joinPointPath: path, + variantPathMap + }); + }); + + it("should return a script that prepares a join point function that will require the join point, when executed", () => { + expect(importCode).toMatch(`const joinPoint = () => require("${path}");`); + }); + + it("should return a script that creates a Map of variants, keyed by relative path, valued as a function that will require the variant module when executed", () => { + expect(importCode).toMatch(`const variantPathMap = new Map([ + ["/test-sub-folder/test-variant-1", () => require("${path}${relativePaths[0]}")], + ["/test-sub-folder/test-variant-2", () => require("${path}${relativePaths[1]}")], + ["/test-other-sub-folder/test-variant-1", () => require("${path}${relativePaths[2]}")] +]);`); + }); + }); + + describe("pack", () => { + it("should not export a pack function, so that the default (identity function) is used", () => { + expect(namespace.pack).toBe(undefined); + }); + }); + + describe("unpack", () => { + it("should call the expression passed to it as a function, and return the result", () => { + const expected = Symbol("test"); + const expression = () => expected; + expect(namespace.unpack(expression)).toBe(expected); + }); + }); +}); diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/createVariantPathMap.js b/packages/webpack/src/moduleLoadStrategyFactories/internal/createVariantPathMap.js similarity index 100% rename from packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/createVariantPathMap.js rename to packages/webpack/src/moduleLoadStrategyFactories/internal/createVariantPathMap.js diff --git a/packages/webpack/src/moduleLoadStrategyFactories/internal/dynamicLoadCodeGenerator.js b/packages/webpack/src/moduleLoadStrategyFactories/internal/dynamicLoadCodeGenerator.js new file mode 100644 index 0000000..7510319 --- /dev/null +++ b/packages/webpack/src/moduleLoadStrategyFactories/internal/dynamicLoadCodeGenerator.js @@ -0,0 +1,10 @@ +import createVariantPathMap from "./createVariantPathMap"; + +const dynamicLoadCodeGenerator = ( + method, + webpackMagicComment = "", + { joinPointPath, variantPathMap } +) => `const joinPoint = () => ${method}(${webpackMagicComment}"${joinPointPath}"); +${createVariantPathMap([...variantPathMap.keys()].map((key) => ` ["${key}", () => ${method}(${webpackMagicComment}"${variantPathMap.get(key)}")]`).join(",\n"))}`; + +export default dynamicLoadCodeGenerator; diff --git a/packages/webpack/src/moduleLoadStrategyFactories/staticLoadStrategyFactory.js b/packages/webpack/src/moduleLoadStrategyFactories/staticLoadStrategyFactory.js new file mode 100644 index 0000000..fc7361b --- /dev/null +++ b/packages/webpack/src/moduleLoadStrategyFactories/staticLoadStrategyFactory.js @@ -0,0 +1,32 @@ +import createVariantPathMap from "./internal/createVariantPathMap.js"; + +const adapterModuleSpecifier = import.meta.filename; + +const importCodeGenerator = ({ joinPointPath, variantPathMap }) => { + const variantsKeys = Array.from(variantPathMap.keys()); + return `import * as joinPoint from "${joinPointPath}"; +${variantsKeys + .map( + (key, index) => + `import * as variant_${index} from "${variantPathMap.get(key)}";` + ) + .join("\n")} +${createVariantPathMap(variantsKeys.map((key, index) => ` ["${key}", variant_${index}]`).join(",\n"))}`; +}; + +/** + * A load strategy factory to generate a load strategy using a {@link https://webpack.js.org/api/module-methods/#import|static (eagerly called) import} of the join point and its variants. + * N.B. As a consequence, the side-effects of all variations and the join point will be executed as soon as the entry point of the chunk in which the code sits is loaded. + * @memberOf module:web-toggle-point-webpack + * @returns {module:web-toggle-point-webpack.loadStrategy} + */ +const staticLoadStrategyFactory = () => + /** + * @implements {module:web-toggle-point-webpack.loadStrategy} + */ + ({ + adapterModuleSpecifier, + importCodeGenerator + }); + +export default staticLoadStrategyFactory; diff --git a/packages/webpack/src/moduleLoadStrategyFactories/staticLoadStrategyFactory.test.js b/packages/webpack/src/moduleLoadStrategyFactories/staticLoadStrategyFactory.test.js new file mode 100644 index 0000000..bee9373 --- /dev/null +++ b/packages/webpack/src/moduleLoadStrategyFactories/staticLoadStrategyFactory.test.js @@ -0,0 +1,72 @@ +/* eslint-disable import/namespace */ +import staticLoadStrategyFactory, * as namespace from "./staticLoadStrategyFactory.js"; + +const path = "/test-folder/test-path"; +const relativePaths = [ + "/test-sub-folder/test-variant-1", + "/test-sub-folder/test-variant-2", + "/test-other-sub-folder/test-variant-1" +]; +const variantPathMap = new Map( + relativePaths.map((relativePath) => [relativePath, `${path}${relativePath}`]) +); + +describe("staticLoadStrategyFactory", () => { + let result; + beforeEach(() => { + result = staticLoadStrategyFactory(); + }); + + it("should return an object containing the adapterModuleSpecifier, indicating the location of the factory code file (so that named exports for the pack and unpack functions can be included in the webpack compilation) and importCodeGenerator", () => { + expect(result).toEqual( + expect.objectContaining({ + adapterModuleSpecifier: expect.stringMatching( + /packages([/\\])webpack\1src\1moduleLoadStrategyFactories\1staticLoadStrategyFactory\.js$/ + ), + importCodeGenerator: expect.any(Function) + }) + ); + }); + + describe("when the importCodeGenerator is called", () => { + let importCode; + + beforeEach(() => { + importCode = result.importCodeGenerator({ + joinPointPath: path, + variantPathMap + }); + }); + + it("should return a script that imports the base / control module for the join point", () => { + expect(importCode).toMatch(`import * as joinPoint from "${path}";`); + }); + + it("should return a script that imports all the valid variants of the base / control module, storing in variables", () => { + expect(importCode).toMatch(` +import * as variant_0 from "${path}${relativePaths[0]}"; +import * as variant_1 from "${path}${relativePaths[1]}"; +import * as variant_2 from "${path}${relativePaths[2]}";`); + }); + + it("should return a script that creates a Map of variants, keyed by relative path, valued as the variant module", () => { + expect(importCode).toMatch(`const variantPathMap = new Map([ + ["/test-sub-folder/test-variant-1", variant_0], + ["/test-sub-folder/test-variant-2", variant_1], + ["/test-other-sub-folder/test-variant-1", variant_2] +]);`); + }); + }); + + describe("pack", () => { + it("should not export a pack function, so that the default (identity function) is used", () => { + expect(namespace.pack).toBe(undefined); + }); + }); + + describe("unpack", () => { + it("should not export an unpack function, so that the default (identity function) is used", () => { + expect(namespace.unpack).toBe(undefined); + }); + }); +}); diff --git a/packages/webpack/src/plugins/index.js b/packages/webpack/src/plugins/index.js index 6fb44a0..30e147c 100644 --- a/packages/webpack/src/plugins/index.js +++ b/packages/webpack/src/plugins/index.js @@ -1 +1 @@ -export { default as TogglePointInjection } from "./togglePointInjection/index.js"; +export { default as TogglePointInjectionPlugin } from "./togglePointInjection/index.js"; diff --git a/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.js b/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.js index a23fbbe..bd9d1f9 100644 --- a/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.js +++ b/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.js @@ -1,18 +1,23 @@ import { posix, basename } from "path"; import webpack from "webpack"; +import deferredRequireLoadStrategyFactory from "../../moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.js"; + +const defaultLoadStrategy = deferredRequireLoadStrategyFactory(); const fillDefaultPointcutValues = (pointCut) => { const { - variantGlobs = ["./**/__variants__/*/*/!(*.test).{js,jsx,ts,tsx}"], + variantGlob = "./**/__variants__/*/*/!(*.test).{js,jsx,ts,tsx}", joinPointResolver = (variantPath) => posix.resolve(variantPath, ...Array(4).fill(".."), basename(variantPath)), - toggleHandler = "@asos/web-toggle-point-webpack/pathSegmentToggleHandler" + toggleHandlerFactoryModuleSpecifier = "@asos/web-toggle-point-webpack/toggleHandlerFactories/pathSegment", + loadStrategy = defaultLoadStrategy } = pointCut; return { ...pointCut, - variantGlobs, + variantGlob, joinPointResolver, - toggleHandler + loadStrategy, + toggleHandlerFactoryModuleSpecifier }; }; diff --git a/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.test.js b/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.test.js index 7e5a2f4..d18aea7 100644 --- a/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/fillDefaultOptionalValues.test.js @@ -1,7 +1,10 @@ -import webpack from "webpack"; -import fillDefaultOptionalValues from "./fillDefaultOptionalValues.js"; +import deferredRequireLoadStrategyFactory from "../../moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.js"; -jest.mock("webpack", () => ({ NormalModule: Symbol("test-normal-module") })); +const mockDeferredRequireLoadStrategy = Symbol("test-default-load-strategy"); +jest.mock( + "../../moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory.js", + () => jest.fn(() => mockDeferredRequireLoadStrategy) +); describe("fillDefaultOptionalValues", () => { let result; @@ -17,61 +20,51 @@ describe("fillDefaultOptionalValues", () => { }); }; - const variantGlobs = Symbol("test-variant-globs"); + const variantGlob = Symbol("test-variant-glob"); const joinPointResolver = Symbol("test-join-point-resolver"); + const loadStrategy = Symbol("test-load-strategy"); - const defaultVariantGlobs = [ - "./**/__variants__/*/*/!(*.test).{js,jsx,ts,tsx}" - ]; + const defaultVariantGlob = "./**/__variants__/*/*/!(*.test).{js,jsx,ts,tsx}"; const defaultJoinPointResolver = expect.any(Function); - const defaultToggleHandler = - "@asos/web-toggle-point-webpack/pathSegmentToggleHandler"; - const toggleHandler = Symbol("test-toggle-handler"); - const webpackNormalModule = Symbol("test-webpack-normal-module"); - - describe("when configuring the plugin with a supplied webpackNormalModule", () => { - beforeEach(() => { - result = fillDefaultOptionalValues({ - webpackNormalModule, - pointCuts: [] - }); - }); - - it("should return the supplied webpackNormalModule", () => { - expect(result.webpackNormalModule).toBe(webpackNormalModule); - }); - }); - - describe("when configuring the plugin without supplying a webpackNormalModule", () => { - beforeEach(() => { - result = fillDefaultOptionalValues({ - pointCuts: [] - }); - }); - - it("should return the NormalModule from the webpack import", () => { - expect(result.webpackNormalModule).toBe(webpack.NormalModule); - }); - }); + const defaultToggleHandlerFactoryModuleSpecifier = + "@asos/web-toggle-point-webpack/toggleHandlerFactories/pathSegment"; + const toggleHandlerFactoryModuleSpecifier = Symbol( + "test-toggle-handler-factory-module-specifier" + ); describe.each` - variantGlobs | joinPointResolver | toggleHandler | description | expectation - ${undefined} | ${undefined} | ${undefined} | ${"nothing"} | ${{ variantGlobs: defaultVariantGlobs, joinPointResolver: defaultJoinPointResolver, toggleHandler: defaultToggleHandler }} - ${variantGlobs} | ${undefined} | ${undefined} | ${"a variantGlob, but nothing else"} | ${{ variantGlobs, joinPointResolver: defaultJoinPointResolver, toggleHandler: defaultToggleHandler }} - ${variantGlobs} | ${joinPointResolver} | ${undefined} | ${"a variantGlob and a join point resolver"} | ${{ variantGlobs, joinPointResolver, toggleHandler: defaultToggleHandler }} - ${undefined} | ${joinPointResolver} | ${undefined} | ${"a joinPointResolver, but nothing else"} | ${{ variantGlobs: defaultVariantGlobs, joinPointResolver, toggleHandler: defaultToggleHandler }} - ${undefined} | ${undefined} | ${toggleHandler} | ${"a toggle handler "} | ${{ variantGlobs: defaultVariantGlobs, joinPointResolver: defaultJoinPointResolver, toggleHandler }} - ${variantGlobs} | ${undefined} | ${toggleHandler} | ${"a toggle handler and a variantGlob, but nothing else"} | ${{ variantGlobs, joinPointResolver: defaultJoinPointResolver, toggleHandler }} - ${variantGlobs} | ${joinPointResolver} | ${toggleHandler} | ${"a toggle handler, a variantGlob and a join point resolver"} | ${{ variantGlobs, joinPointResolver, toggleHandler }} - ${undefined} | ${joinPointResolver} | ${toggleHandler} | ${"a toggle handler and a joinPointResolver, but nothing else"} | ${{ variantGlobs: defaultVariantGlobs, joinPointResolver, toggleHandler }} + variantGlob | joinPointResolver | loadStrategy | toggleHandlerFactoryModuleSpecifier | description | expectation + ${undefined} | ${undefined} | ${undefined} | ${undefined} | ${"nothing"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${undefined} | ${undefined} | ${undefined} | ${"a variantGlob, but nothing else"} | ${{ variantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${joinPointResolver} | ${undefined} | ${undefined} | ${"a variantGlob and a join point resolver"} | ${{ variantGlob, joinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${undefined} | ${loadStrategy} | ${undefined} | ${"a variantGlob and a load strategy"} | ${{ variantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${joinPointResolver} | ${loadStrategy} | ${undefined} | ${"a variantGlob, a join point resolver and a load strategy"} | ${{ variantGlob, joinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${undefined} | ${joinPointResolver} | ${undefined} | ${undefined} | ${"a joinPointResolver, but nothing else"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${undefined} | ${joinPointResolver} | ${loadStrategy} | ${undefined} | ${"a joinPointResolver and a load strategy"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${undefined} | ${undefined} | ${loadStrategy} | ${undefined} | ${"a load strategy, but nothing else"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: defaultToggleHandlerFactoryModuleSpecifier }} + ${undefined} | ${undefined} | ${undefined} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${undefined} | ${undefined} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier and a variantGlob, but nothing else"} | ${{ variantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${joinPointResolver} | ${undefined} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier, a variantGlob and a join point resolver"} | ${{ variantGlob, joinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${undefined} | ${loadStrategy} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier, variantGlob and a load strategy"} | ${{ variantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} + ${variantGlob} | ${joinPointResolver} | ${loadStrategy} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier, variantGlob, a join point resolver and a load strategy"} | ${{ variantGlob, joinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} + ${undefined} | ${joinPointResolver} | ${undefined} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier and a joinPointResolver, but nothing else"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver, loadStrategy: mockDeferredRequireLoadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} + ${undefined} | ${joinPointResolver} | ${loadStrategy} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier, joinPointResolver and a load strategy"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} + ${undefined} | ${undefined} | ${loadStrategy} | ${toggleHandlerFactoryModuleSpecifier} | ${"a toggle handler factory module specifier and a load strategy, but nothing else"} | ${{ variantGlob: defaultVariantGlob, joinPointResolver: defaultJoinPointResolver, loadStrategy, toggleHandlerFactoryModuleSpecifier: toggleHandlerFactoryModuleSpecifier }} `( "when configuring pointCuts, supplying $description", // eslint-disable-next-line no-unused-vars ({ expectation, description, ...pointCut }) => { beforeEach(async () => { + const { default: fillDefaultOptionalValues } = await import( + "./fillDefaultOptionalValues.js" + ); result = fillDefaultOptionalValues({ pointCuts: [pointCut] }); }); + it("should have retrieved the default load strategy", () => { + expect(deferredRequireLoadStrategyFactory).toHaveBeenCalled(); + }); + it("should fill the defaults", () => { expect(result.pointCuts[0]).toEqual(expectation); }); diff --git a/packages/webpack/src/plugins/togglePointInjection/index.js b/packages/webpack/src/plugins/togglePointInjection/index.js index 9f10076..ec3c24a 100644 --- a/packages/webpack/src/plugins/togglePointInjection/index.js +++ b/packages/webpack/src/plugins/togglePointInjection/index.js @@ -18,23 +18,30 @@ class TogglePointInjection { * Create a {@link https://webpack.js.org/concepts/plugins/|Plugin} that injects toggle points into a Webpack build * @param {object} options plugin options * @param {object[]} options.pointCuts toggle point point cut configuration, with target toggle point code as advice. The first matching point cut will be used - * @param {string} options.pointCuts[].name name to describe the nature of the point cut, for clarity in logs and dev tools etc. - * @param {string} options.pointCuts[].togglePointModule path, from root of the compilation, of where the toggle point sits. Or a resolvable node_module. - * @param {string[]} [options.pointCuts[].variantGlobs=[.\/**\/__variants__/*\/*\/!(*.test).{js,jsx,ts,tsx}]] {@link https://en.wikipedia.org/wiki/Glob_(programming)|Globs} to identified variant modules. The plugin uses {@link https://github.com/mrmlnc/fast-glob|fast-glob} under the hood, so supports any glob that it does. - * @param {function} [options.pointCuts[].joinPointResolver=(variantPath) => path.posix.resolve(variantPath, "../../../..", path.basename(variantPath))] A function that takes the path to a variant module and returns a join point / base module. N.B. This is executed at build-time, so cannot use run-time context. It should use posix path segments, so on Windows be sure to use path.posix.resolve. - * @param {string} [options.pointCuts[].toggleHandler='@asos/web-toggle-point-webpack/pathSegmentToggleHandler'] a {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script|module specifier} pointing to a toggle handler, that takes a toggle point, a Map of relative paths to potential variants, and a join point. If not provided, the plugin will use a default handler that processes folder names into a tree held in a Map. - * @param {function} [options.webpackNormalModule] A reference to the Webpack NormalModule class. This is required for Next.js, as it does not expose the NormalModule class directly + * @param {string} options.pointCuts[].name name to describe the nature of the point cut, for clarity in logs and dev tools etc + * @param {string} options.pointCuts[].togglePointModuleSpecifier a {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script|module specifier} pointing to the toggle point module + * @param {string} [options.pointCuts[].variantGlobs=['.\/**\/__variants__/*\/*\/!(*.test).{js,jsx,ts,tsx}']] {@link https://en.wikipedia.org/wiki/Glob_(programming)|Glob} to identified variant modules. The plugin uses {@link https://github.com/mrmlnc/fast-glob|fast-glob} under the hood, so supports any glob that it does + * @param {function} [options.pointCuts[].joinPointResolver=(variantPath) => path.posix.resolve(variantPath, "../../../..", path.basename(variantPath))] A function that takes the path to a variant module and returns a join point / base module. N.B. This is executed at build-time, so cannot use run-time context. It should use posix path segments, so on Windows be sure to use path.posix.resolve + * @param {string} [options.pointCuts[].toggleHandlerFactoryModuleSpecifier='@asos/web-toggle-point-webpack/toggleHandlerFactories/pathSegment'] a {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#importing_features_into_your_script|module specifier} pointing to a toggle handler factory, that takes a toggle point, and returns a handler that unpicks a Map of relative paths to potential variants, passing that plus a joint point. If not provided, the plugin will use a default handler that processes folder names into a tree held in a Map. The join point and leaf nodes of the tree are modules in a form defined by the loading strategy + * @param {module:web-toggle-point-webpack.loadStrategy} [options.pointCuts[].loadStrategy] a module load strategy. default is one created by "@asos/web-toggle-point-webpack/moduleLoadStrategyFactories/deferredRequireLoadStrategyFactory" + * @param {function} [options.webpackNormalModule] A function that returns the Webpack NormalModule class. This is required for Next.js, as it does not expose the NormalModule class directly * @returns {external:Webpack.WebpackPluginInstance} WebpackPluginInstance - * @example N.B. forward slashes are escaped in the glob, due to JSDoc shortcomings, but in reality should be un-escaped + * @example N.B. forward slashes + asterisk are escaped in the examples, due to JSDoc shortcomings, but in reality should be un-escaped + * import loadStrategyFactory from "@asos/web-toggle-point-react-pointcuts/lazyComponentLoadStrategyFactory"; * const plugin = new TogglePointInjection({ * pointCuts: [ * { - * togglePointModule: "/withToggledHook", + * togglePointModuleSpecifier: "/withToggledHook", * variantGlobs: ["./**\/__variants__/*\/*\/use!(*.test).{ts,tsx}"] * }, * { - * togglePointModule: "/withTogglePoint", - * variantGlobs: ["./**\/__variants__/*\/*\/!(use*|*.test).{ts,tsx}"] + * togglePointModuleSpecifier: "/withTogglePoint", + * variantGlobs: ["./**\/__variants__/*\/*\/!(use*|*.test).tsx"], + * loadStrategy: loadStrategyFactory({ + * importCodeGeneratorFactoryOptions: { + * webpackMagicComment: "/* webpackPrefetch *\/" + * } + * }) * } * ] * }); diff --git a/packages/webpack/src/plugins/togglePointInjection/index.test.js b/packages/webpack/src/plugins/togglePointInjection/index.test.js index d2db6bb..af3e596 100644 --- a/packages/webpack/src/plugins/togglePointInjection/index.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/index.test.js @@ -32,7 +32,10 @@ jest.mock("./fillDefaultOptionalValues.js", () => describe("togglePointInjection", () => { let togglePointInjection, compiler, options; - const pointCuts = [{ name: "test-name", togglePointModule: "test-module" }]; + const pointCuts = [ + { [Symbol("test-key")]: Symbol("test-value") }, + { [Symbol("test-key")]: Symbol("test-value") } + ]; beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/webpack/src/plugins/togglePointInjection/integration.test.js b/packages/webpack/src/plugins/togglePointInjection/integration.test.js index 07ec825..fdc7c9c 100644 --- a/packages/webpack/src/plugins/togglePointInjection/integration.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/integration.test.js @@ -3,6 +3,9 @@ import { readFile } from "fs/promises"; import { posix } from "path"; import TogglePointInjection from "./index.js"; import { PLUGIN_NAME } from "./constants.js"; +import staticLoadStrategyFactory from "../../moduleLoadStrategyFactories/staticLoadStrategyFactory"; + +const loadStrategy = staticLoadStrategyFactory(); describe("togglePointInjection", () => { let plugin, fileSystem, built; @@ -18,28 +21,27 @@ describe("togglePointInjection", () => { const testCases = [ { name: "not react hooks", - togglePointModule: togglePointModule1, + togglePointModuleSpecifier: togglePointModule1, variantGlobs: [`${modulesFolder}**/${variantsFolder}/*/*/!(*use)*.js`], - moduleName: "testModule.js" + moduleName: "testModule.js", + loadStrategy }, { name: "react hooks", - togglePointModule: togglePointModule2, + togglePointModuleSpecifier: togglePointModule2, variantGlobs: [`${modulesFolder}**/${variantsFolder}/*/*/use*.js`], - moduleName: "useTestModule.js" + moduleName: "useTestModule.js", + loadStrategy } ]; beforeEach(async () => { fileSystem = { - "node_modules/@asos/web-toggle-point-webpack/pathSegmentToggleHandler": + "node_modules/@asos/web-toggle-point-webpack/toggleHandlerFactories/pathSegment": await readFile( posix.resolve( __dirname, - "..", - "..", - "toggleHandlers", - "pathSegmentToggleHandler.js" + "../../toggleHandlerFactories/pathSegment.js" ), "utf8" ) @@ -62,7 +64,7 @@ describe("togglePointInjection", () => { ...fileSystem, "/src/index.js": `export { default } from "${modulesFolder}${moduleName}";`, [`${togglePointModule1}.js`]: - "export default (joinPointModule, featuresMap) => ({ joinPointModule, featuresMap })", + "export default ({ joinPoint, featuresMap, unpack }) => ({ joinPoint, featuresMap, unpack })", [`${modulesFolder}${variantsFolder}/${testFeature}/${testVariant}/not-matching-name.js`]: `export default "${variantModuleOutput}";`, [`${modulesFolder}${moduleName}`]: `export default "${baseModuleOutput}";` }, @@ -80,7 +82,7 @@ describe("togglePointInjection", () => { describe.each(testCases)( "when a module is toggled and a matching variant exists", - ({ name, togglePointModule, moduleName }) => { + ({ name, togglePointModuleSpecifier, moduleName }) => { plugin = new TogglePointInjection({ pointCuts: testCases.map(({ moduleName, ...rest }) => rest) // eslint-disable-line no-unused-vars }); @@ -89,8 +91,8 @@ describe("togglePointInjection", () => { { ...fileSystem, "/src/index.js": `export { default } from "${modulesFolder}${moduleName}";`, - [`${togglePointModule}.js`]: - "export default (joinPointModule, featuresMap) => ({ joinPointModule, featuresMap })", + [`${togglePointModuleSpecifier}.js`]: + "export default ({ joinPoint, featuresMap, unpack }) => ({ joinPoint, featuresMap, unpack })", [`${modulesFolder}${variantsFolder}/${testFeature}/${testVariant}/${moduleName}`]: `export default "${variantModuleOutput}";`, [`${modulesFolder}${moduleName}`]: `export default "${baseModuleOutput}";` }, @@ -106,13 +108,14 @@ describe("togglePointInjection", () => { ); }); - it("should pass the toggled base module and a Map containing the matched variant to the module at the togglePointModule", () => { + it("should pass the toggled base module and a Map containing the matched variant to the module at the togglePointModuleSpecifier", async () => { const result = built.require("/dist/index.js"); expect(result).toMatchObject({ - joinPointModule: { + joinPoint: { default: baseModuleOutput }, - featuresMap: expect.anything() // jest doesn't have a built-in way to check if an object is a Map + featuresMap: expect.anything(), // jest doesn't have a built-in way to check if an object is a Map + unpack: expect.any(Function) }); const { featuresMap } = result; expect(featuresMap.has(testFeature)).toBe(true); @@ -138,7 +141,7 @@ describe("togglePointInjection", () => { joinPoints: [] }), [`${togglePointModule1}.js`]: - "export default (joinPointModule, featuresMap) => ({ joinPointModule, featuresMap })", + "export default ({ joinPoint, featuresMap, unpack }) => ({ joinPoint, featuresMap, unpack })", [`${modulesFolder}${variantsFolder}/${testFeature}/${testVariant}/${moduleName}`]: `export default "${variantModuleOutput}";`, [`${modulesFolder}${moduleName}`]: `export default "${baseModuleOutput}";` }, @@ -169,7 +172,7 @@ describe("togglePointInjection", () => { ...fileSystem, "/src/index.js": `export { default } from "${modulesFolder}${moduleName}";`, [`${togglePointModule1}.js`]: - "export default (joinPointModule, featuresMap) => ({ joinPointModule, featuresMap })", + "export default ({ joinPoint, featuresMap, unpack }) => ({ joinPoint, featuresMap, unpack })", [`${modulesFolder}${variantsFolder}/${testFeature}/${testVariant}/${moduleName}`]: `export default "${variantModuleOutput}";`, [`${modulesFolder}${moduleName}`]: `export default "${baseModuleOutput}";` }, diff --git a/packages/webpack/src/plugins/togglePointInjection/processPointCuts/index.test.js b/packages/webpack/src/plugins/togglePointInjection/processPointCuts/index.test.js index 71f9ecd..4943b17 100644 --- a/packages/webpack/src/plugins/togglePointInjection/processPointCuts/index.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/processPointCuts/index.test.js @@ -1,6 +1,6 @@ +import processPointCuts from "./index.js"; import processVariantFiles from "./processVariantFiles/index.js"; import getVariantPaths from "./getVariantPaths.js"; -import processPointCuts from "./index.js"; jest.mock("./processVariantFiles/index", () => jest.fn()); jest.mock("./getVariantPaths", () => @@ -9,17 +9,18 @@ jest.mock("./getVariantPaths", () => describe("processPointCuts", () => { const pointCuts = new Map([ - ["test-key-1", Symbol("test-point-cut")], - ["test-key-2", Symbol("test-point-cut")], - ["test-key-3", Symbol("test-point-cut")] + ["test-key-1", { ["test-key"]: Symbol("test-point-cut") }], + ["test-key-2", { ["test-key"]: Symbol("test-point-cut") }], + ["test-key-3", { ["test-key"]: Symbol("test-point-cut") }] ]); const pointCutsValues = Array.from(pointCuts.values()); - const appRoot = Symbol("test-app-root"); + const appRoot = "test-app-root"; const fileSystem = Symbol("test-file-system"); let warnings, joinPointFiles; beforeEach(async () => { jest.clearAllMocks(); + ({ warnings, joinPointFiles } = await processPointCuts({ appRoot, fileSystem, @@ -57,7 +58,7 @@ describe("processPointCuts", () => { ).toEqual(1); }); - it("should return an array of warnings an a map of join point files", () => { + it("should return an array of warnings and a Map of join point files", () => { expect(warnings).toBeInstanceOf(Array); expect(joinPointFiles).toBeInstanceOf(Map); }); diff --git a/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/isJoinPointInvalid.js b/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/isJoinPointInvalid.js index c88885a..b88efbe 100644 --- a/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/isJoinPointInvalid.js +++ b/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/isJoinPointInvalid.js @@ -41,10 +41,8 @@ const isJoinPointInvalid = async ({ }) => { await ensureConfigFile({ configFiles, fileSystem, directory, appRoot }); - if (configFiles.has(directory)) { - if (configFiles.get(directory)?.joinPoints.includes(filename) === false) { - return true; - } + if (configFiles.get(directory)?.joinPoints.includes(filename) === false) { + return true; } if (!(await fileExists(fileSystem, join(appRoot, joinPointPath)))) { diff --git a/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/validateConfigSchema/index.test.js b/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/validateConfigSchema/index.test.js index 8899431..bae1c46 100644 --- a/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/validateConfigSchema/index.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/processPointCuts/processVariantFiles/validateConfigSchema/index.test.js @@ -1,11 +1,12 @@ -import validateConfigSchema from "./index"; +import validateConfigSchema from "."; import { PLUGIN_NAME } from "../../../constants"; import { validate } from "schema-utils"; +import configSchema from "./configSchema.json"; jest.mock("schema-utils", () => ({ validate: jest.fn() })); -jest.mock("./configSchema.json", () => ({})); +jest.mock("./configSchema.json", () => Symbol("test-config-schema")); jest.mock("../../../constants", () => ({ PLUGIN_NAME: "test-plugin-name" })); @@ -20,7 +21,7 @@ describe("validateConfigSchema", () => { }); it("should validate the schema of the config file, and output with the plugin name and 'toggle config' as the data path that errored", () => { - expect(validate).toHaveBeenCalledWith({}, configFile, { + expect(validate).toHaveBeenCalledWith(configSchema, configFile, { name: PLUGIN_NAME, baseDataPath: "toggle config", postFormatter: expect.any(Function) diff --git a/packages/webpack/src/plugins/togglePointInjection/resolveJoinPoints/handleJoinPointMatch/index.test.js b/packages/webpack/src/plugins/togglePointInjection/resolveJoinPoints/handleJoinPointMatch/index.test.js index 34ebd7e..70e9ad7 100644 --- a/packages/webpack/src/plugins/togglePointInjection/resolveJoinPoints/handleJoinPointMatch/index.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/resolveJoinPoints/handleJoinPointMatch/index.test.js @@ -1,7 +1,7 @@ import handleJoinPointMatch from "."; -import { SCHEME, JOIN_POINTS } from "../../constants"; import getIssuerModule from "./getIssuerModule"; import resourceProxyExistsInRequestChain from "./resourceProxyExistsInRequestChain"; +import { SCHEME, JOIN_POINTS } from "../../constants"; jest.mock("./getIssuerModule", () => jest.fn()); jest.mock("./resourceProxyExistsInRequestChain", () => jest.fn()); @@ -18,8 +18,9 @@ describe("handleJoinPointMatch", () => { const mockOriginalRequest = Symbol("test-original-request"); let resolveData; - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); + resolveData = { request: mockOriginalRequest }; diff --git a/packages/webpack/src/plugins/togglePointInjection/schema.json b/packages/webpack/src/plugins/togglePointInjection/schema.json index b75b215..80ad52a 100644 --- a/packages/webpack/src/plugins/togglePointInjection/schema.json +++ b/packages/webpack/src/plugins/togglePointInjection/schema.json @@ -6,30 +6,38 @@ "items": { "type": "object", "properties": { - "name": { - "type": "string" + "joinPointResolver": { + "instanceof": "Function" }, - "togglePointModule": { + "loadStrategy": { + "type": "object", + "properties": { + "importCodeGenerator": { + "instanceof": "Function" + }, + "pack": { + "instanceof": "Function" + }, + "unpack": { + "instanceof": "Function" + } + }, + "required": ["importCodeGenerator"] + }, + "name": { "type": "string" }, + "toggleHandlerFactoryModuleSpecifier": { "type": "string" }, + "togglePointModuleSpecifier": { "type": "string" }, "variantGlobs": { "type": "array", "items": { "type": "string" } - }, - "toggleHandler": { - "type": "string" - }, - "joinPointResolver": { - "instanceof": "Function" } }, "additionalProperties": false, - "required": [ - "name", - "togglePointModule" - ] + "required": ["name", "togglePointModuleSpecifier"] } }, "webpackNormalModule": { @@ -37,7 +45,5 @@ } }, "additionalProperties": false, - "required": [ - "pointCuts" - ] + "required": ["pointCuts"] } diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/index.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint.js similarity index 75% rename from packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/index.js rename to packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint.js index 58b7ea6..d212738 100644 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/index.js +++ b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint.js @@ -1,9 +1,11 @@ -import { POINT_CUTS, SCHEME } from "../../constants.js"; -import importCodeGenerator from "./importCodeGenerator.js"; +import { POINT_CUTS, SCHEME } from "../constants.js"; const generateJoinPoint = ({ joinPointFiles, joinPointPath }) => { const { - pointCut: { name }, + pointCut: { + name, + loadStrategy: { importCodeGenerator } + }, variantPathMap } = joinPointFiles.get(joinPointPath); const pointCutImport = `import pointCut from "${SCHEME}:${POINT_CUTS}:/${name}";`; diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/index.test.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint.test.js similarity index 76% rename from packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/index.test.js rename to packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint.test.js index 3a114d8..dd8aa59 100644 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/index.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint.test.js @@ -1,21 +1,21 @@ -import { POINT_CUTS, SCHEME } from "../../constants.js"; -import generateJoinPoint from "./index.js"; -import importCodeGenerator from "./importCodeGenerator.js"; +import { POINT_CUTS, SCHEME } from "../constants.js"; +import generateJoinPoint from "./generateJoinPoint.js"; -jest.mock("../../constants", () => ({ +jest.mock("../constants", () => ({ SCHEME: "test-scheme", POINT_CUTS: "test-point-cuts" })); -const mockImportCode = - "const joinPoint = 'test-join-point'; const variantPathMap = 'test-variants';"; -jest.mock("./importCodeGenerator.js", () => jest.fn(() => mockImportCode)); describe("generateJoinPoint", () => { const joinPointPath = "/test-path"; const pointCutName = "test-point-cut"; const variantPathMap = Symbol("test-variant-path-map"); + const mockImportCode = + "const joinPoint = 'test-join-point'; const variantPathMap = 'test-variants';"; + const importCodeGenerator = jest.fn(() => mockImportCode); const pointCut = { - name: pointCutName + name: pointCutName, + loadStrategy: { importCodeGenerator } }; let result; diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/createVariantPathMap.test.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/createVariantPathMap.test.js deleted file mode 100644 index cabf55a..0000000 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/createVariantPathMap.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import createVariantPathMap from "./createVariantPathMap"; - -describe("createVariantPathMap", () => { - it("should return code to create a variantPathMap constant, wrapping the supplied content in a Map", () => { - const testContent = "test-content"; - expect(createVariantPathMap(testContent)) - .toEqual(`const variantPathMap = new Map([ -${testContent} -]);`); - }); -}); diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/importCodeGenerator.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/importCodeGenerator.js deleted file mode 100644 index 49a6583..0000000 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/importCodeGenerator.js +++ /dev/null @@ -1,15 +0,0 @@ -import createVariantPathMap from "./createVariantPathMap.js"; - -const importCodeGenerator = ({ joinPointPath, variantPathMap }) => { - const variantsKeys = Array.from(variantPathMap.keys()); - return `import * as joinPoint from "${joinPointPath}"; -${variantsKeys - .map( - (key, index) => - `import * as variant_${index} from "${variantPathMap.get(key)}";` - ) - .join("\n")} -${createVariantPathMap(variantsKeys.map((key, index) => ` ["${key}", variant_${index}]`).join(",\n"))}`; -}; - -export default importCodeGenerator; diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/importCodeGenerator.test.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/importCodeGenerator.test.js deleted file mode 100644 index f80d411..0000000 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generateJoinPoint/importCodeGenerator.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import importCodeGenerator from "./importCodeGenerator"; - -describe("importCodeGenerator", () => { - const joinPointPath = "/test-folder/test-path"; - const relativePaths = [ - "/test-sub-folder/test-variant-1", - "/test-sub-folder/test-variant-2", - "/test-other-sub-folder/test-variant-1" - ]; - const variantPathMap = new Map( - relativePaths.map((relativePath) => [ - relativePath, - `${joinPointPath}${relativePath}` - ]) - ); - - let result; - - beforeEach(() => { - result = importCodeGenerator({ - joinPointPath, - variantPathMap - }); - }); - - it("should return code that imports the base / control module for the join point", () => { - expect(result).toMatch(`import * as joinPoint from "${joinPointPath}";`); - }); - - it("should return code that imports all the valid variants of the base / control module, storing in variables", () => { - expect(result).toMatch(` -import * as variant_0 from "${joinPointPath}${relativePaths[0]}"; -import * as variant_1 from "${joinPointPath}${relativePaths[1]}"; -import * as variant_2 from "${joinPointPath}${relativePaths[2]}";`); - }); - - it("should return code that creates a Map of variants, keyed by relative path, valued as the variant module", () => { - expect(result).toMatch(`const variantPathMap = new Map([ - ["/test-sub-folder/test-variant-1", variant_0], - ["/test-sub-folder/test-variant-2", variant_1], - ["/test-other-sub-folder/test-variant-1", variant_2] -]);`); - }); -}); diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.js index 4361c83..2565c20 100644 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.js +++ b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.js @@ -1,13 +1,19 @@ +import { posix, sep } from "path"; + const generatePointCut = ({ pointCuts, joinPointPath }) => { const pointCutName = joinPointPath.slice(1); const { - togglePointModule, - toggleHandler = "@asos/web-toggle-point-webpack/pathSegmentToggleHandler" + togglePointModuleSpecifier, + toggleHandlerFactoryModuleSpecifier, + loadStrategy: { adapterModuleSpecifier } } = pointCuts.find(({ name }) => name === pointCutName); - - return `import togglePoint from "${togglePointModule}"; -import handler from "${toggleHandler}"; -export default (rest) => handler({ togglePoint, ...rest });`; + return `import togglePoint from "${togglePointModuleSpecifier}"; +import handlerFactory from "${toggleHandlerFactoryModuleSpecifier}"; +import * as namespace from "${adapterModuleSpecifier.replaceAll(sep, posix.sep)}"; +const identity = (module) => module; +const { pack:_pack = identity, unpack:_unpack = identity } = namespace; +const handler = handlerFactory({ togglePoint, pack: _pack, unpack: _unpack }); +export default handler;`; }; export default generatePointCut; diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.test.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.test.js index f825a7c..55d0f92 100644 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/generatePointCut.test.js @@ -1,56 +1,91 @@ import generatePointCut from "./generatePointCut.js"; +import { posix, sep } from "path"; describe("generatePointCut", () => { const pointCutName = "test-point-cut"; const joinPointPath = `/${pointCutName}`; - const togglePointModule = "test-toggle-point-path"; + const togglePointModuleSpecifier = "test-toggle-point-path"; + const toggleHandlerFactoryModuleSpecifier = + "test-toggle-handler-factory-module-specifier"; + let adapterModuleSpecifier; let result, pointCuts; - beforeEach(() => { - pointCuts = [ - { name: "test-other-point-cut" }, - { name: pointCutName, togglePointModule } - ]; - }); - const makeCommonAssertions = () => { it("should return a script that imports the appropriate toggle point", () => { - expect(result).toMatch(`import togglePoint from "${togglePointModule}";`); + expect(result).toMatch( + `import togglePoint from "${togglePointModuleSpecifier}";` + ); }); - it("should return a script exports a default export which calls the toggle handler, passing the toggle point and any other properties of the first argument given to it", () => { + it("should return a script that imports the appropriate toggle handler factory", () => { expect(result).toMatch( - "export default (rest) => handler({ togglePoint, ...rest });" + `import handlerFactory from "${toggleHandlerFactoryModuleSpecifier}";` ); }); - }; - describe("when a toggle handler is configured against the point cut", () => { - const toggleHandler = "test-toggle-handler"; + it("should return a script that imports the pack and unpack exports of the appropriate adapter module, via the namespace, falling back to an identity function if the adapter does not export a pack/unpack handler", () => { + // N.B. The pack and unpack functions must be aliased during the import to mitigate https://github.com/webpack/webpack/issues/19518, which although fixed, is still present in some webpack versions (notably NextJS is stuck on webpack 5.98.0 as of EOY 2025). + expect(result).toMatch( + `import * as namespace from "${adapterModuleSpecifier.replaceAll(sep, posix.sep)}"; +const identity = (module) => module; +const { pack:_pack = identity, unpack:_unpack = identity } = namespace;` + ); + }); - beforeEach(() => { - pointCuts[1].toggleHandler = toggleHandler; - result = generatePointCut({ pointCuts, joinPointPath }); + it("should return a script that constructs a toggle handler, passing the toggle point to the factory, plus the pack and unpack functions from the load strategy adapter", () => { + expect(result).toMatch( + "const handler = handlerFactory({ togglePoint, pack: _pack, unpack: _unpack });" + ); + }); + + it("should return a script with a handler default export", () => { + expect(result).toMatch("export default handler;"); }); it("should return a script that imports the appropriate toggle handler", () => { - expect(result).toMatch(`import handler from "${toggleHandler}";`); + expect(result).toMatch( + `import handlerFactory from "${toggleHandlerFactoryModuleSpecifier}";` + ); + }); + }; + + describe("on posix based file systems", () => { + beforeEach(() => { + adapterModuleSpecifier = "/test/path/test-adapter-module-specifier"; + pointCuts = [ + { name: "test-other-point-cut", toggleHandlerFactoryModuleSpecifier }, + { + name: pointCutName, + togglePointModuleSpecifier, + toggleHandlerFactoryModuleSpecifier, + loadStrategy: { + adapterModuleSpecifier + } + } + ]; + result = generatePointCut({ pointCuts, joinPointPath }); }); makeCommonAssertions(); }); - describe("when a toggle handler is not configured against the point cut", () => { + describe("on windows file systems", () => { beforeEach(() => { + adapterModuleSpecifier = "D:\\test\\path\\test-adapter-module-specifier"; + pointCuts = [ + { name: "test-other-point-cut", toggleHandlerFactoryModuleSpecifier }, + { + name: pointCutName, + togglePointModuleSpecifier, + toggleHandlerFactoryModuleSpecifier, + loadStrategy: { + adapterModuleSpecifier + } + } + ]; result = generatePointCut({ pointCuts, joinPointPath }); }); - it("should return a script that imports the default toggle handler (a path segment toggle handler)", () => { - expect(result).toMatch( - `import handler from "@asos/web-toggle-point-webpack/pathSegmentToggleHandler";` - ); - }); - makeCommonAssertions(); }); }); diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.js index db42a9c..2f2508e 100644 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.js +++ b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.js @@ -1,5 +1,5 @@ import { PLUGIN_NAME, POINT_CUTS, JOIN_POINTS, SCHEME } from "../constants.js"; -import generateJoinPoint from "./generateJoinPoint/index.js"; +import generateJoinPoint from "./generateJoinPoint.js"; import generatePointCut from "./generatePointCut.js"; const setupSchemeModules = ({ diff --git a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.test.js b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.test.js index da55147..692cc1c 100644 --- a/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.test.js +++ b/packages/webpack/src/plugins/togglePointInjection/setupSchemeModules/index.test.js @@ -1,5 +1,5 @@ import { PLUGIN_NAME, POINT_CUTS, JOIN_POINTS, SCHEME } from "../constants.js"; -import generateJoinPoint from "./generateJoinPoint/index.js"; +import generateJoinPoint from "./generateJoinPoint.js"; import generatePointCut from "./generatePointCut.js"; import setupSchemeModules from "./index.js"; @@ -24,7 +24,7 @@ describe("setupSchemeModules", () => { const joinPointFiles = Symbol("test-join-point-files"); const pointCuts = Symbol("test-point-cuts"); - beforeEach(() => { + beforeEach(async () => { setupSchemeModules({ NormalModule, compilation, diff --git a/packages/webpack/src/toggleHandlerFactories/pathSegment.js b/packages/webpack/src/toggleHandlerFactories/pathSegment.js new file mode 100644 index 0000000..51b9647 --- /dev/null +++ b/packages/webpack/src/toggleHandlerFactories/pathSegment.js @@ -0,0 +1,60 @@ +const buildTree = (map = new Map(), parts, value) => { + const [part, ...rest] = parts; + if (rest.length) { + map.set(part, buildTree(map.get(part), rest, value)); + } else { + map.set(part, value); + } + return map; +}; + +/** + * Path Segment Toggle Handler Factory + * @memberof module:web-toggle-point-webpack + * @inner + * @param {object} options toggle handler factory options + * @param {function} options.togglePoint a method that chooses the appropriate module at runtime, passed a join points and a Map of feature keys to variants + * @param {function} options.pack a method to pack a module (as returned by the join point) in preparation for use by the toggle point. This should be defined by the {@link module:web-toggle-point-webpack.loadStrategy|load strategy}. + * @param {function} options.unpack a method to unpack a module when needed by the toggle point. This should be defined by the {@link module:web-toggle-point-webpack.loadStrategy|load strategy}. + * @returns {module:web-toggle-point-webpack.pathSegmentToggleHandler} a toggle handler that takes a join point and a Map of feature keys to variants, and returns a module + * @example + * const pathSegmentToggleHandler = pathSegmentToggleHandlerFactory({ + * togglePoint: ({ joinPoint, featuresMap, unpack }) => { + * const choseFeature = ... // toggle point logic, picking either the packed join point or a variant module + * return unpack(chosenFeature); + * }), + * pack: (moduleNamespace) => moduleNamespace(), + * unpack: (moduleNamespace) => moduleNamespace().default + * }); + */ +const pathSegmentToggleHandlerFactory = ({ togglePoint, pack, unpack }) => { + /** + * Path Segment Toggle Handler + * @static + * @memberof module:web-toggle-point-webpack + * @param {object} params handler parameters + * @param {module} params.joinPoint the join point + * @param {Map} params.variantPathMap a Map of posix file paths, relative to the join point module, valued in a form defined by the loading strategy + * @returns {function} A handler of join points injected by the plugin + * @example + * const result = pathSegmentToggleHandler({ + * joinPoint: () => import("/src/some-base-module.js"), + * variantPathMap: new Map([ + * ["/src/variants/some-variant-1.js", () => import("/src/variants/some-variant-1.js")], + * ["/src/variants/some-variant-2.js", () => import("/src/variants/some-variant-2.js")], + * ]), + * }); + */ + const pathSegmentToggleHandler = ({ joinPoint, variantPathMap }) => { + let featuresMap; + for (const [key, value] of variantPathMap) { + const parts = key.split("/").slice(0, -1).slice(2); + featuresMap = buildTree(featuresMap, parts, pack(value)); + } + return togglePoint({ joinPoint: pack(joinPoint), featuresMap, unpack }); + }; + + return pathSegmentToggleHandler; +}; + +export default pathSegmentToggleHandlerFactory; diff --git a/packages/webpack/src/toggleHandlers/pathSegmentToggleHandler.test.js b/packages/webpack/src/toggleHandlerFactories/pathSegment.test.js similarity index 58% rename from packages/webpack/src/toggleHandlers/pathSegmentToggleHandler.test.js rename to packages/webpack/src/toggleHandlerFactories/pathSegment.test.js index ecf556f..5505b35 100644 --- a/packages/webpack/src/toggleHandlers/pathSegmentToggleHandler.test.js +++ b/packages/webpack/src/toggleHandlerFactories/pathSegment.test.js @@ -1,21 +1,28 @@ -import pathSegmentToggleHandler from "./pathSegmentToggleHandler.js"; +import pathSegmentToggleHandlerFactory from "./pathSegment.js"; const toggleOutcome = Symbol("test-outcome"); const togglePoint = jest.fn(() => toggleOutcome); -const joinPoint = Symbol("mock-join-point"); +const pack = jest.fn(() => Symbol("packed")); +const unpack = jest.fn(() => Symbol("unpacked")); -describe("pathSegmentToggleHandler", () => { - let result; +describe("toggleHandlerFactories/pathSegment", () => { + let toggleHandlerFactory; beforeEach(() => { jest.clearAllMocks(); + toggleHandlerFactory = pathSegmentToggleHandlerFactory({ + togglePoint, + pack, + unpack + }); }); [1, 2, 3].forEach((segmentCount) => { const keyArray = [...Array(segmentCount).keys()]; - describe(`given a list of variant paths with ${segmentCount} path segments (after the variants path)`, () => { - let variantPathMap; + describe(`given a Map keyed by variant paths with ${segmentCount} path segments (after the variants path)`, () => { + let result, variantPathMap; + const joinPoint = Symbol("mock-join-point"); beforeEach(() => { const segments = keyArray.map((key) => `test-segment-${key}/`); @@ -29,15 +36,23 @@ describe("pathSegmentToggleHandler", () => { Symbol("test-variant") ] ]); - result = pathSegmentToggleHandler({ - togglePoint, + result = toggleHandlerFactory({ joinPoint, variantPathMap }); }); - it("should call the toggle point with the join point module and a map", () => { - expect(togglePoint).toHaveBeenCalledWith(joinPoint, expect.any(Map)); + it("should pack the join point", () => { + expect(pack).toHaveBeenCalledWith(joinPoint); + }); + + it("should call the toggle point with the join point module and a map, plus the passed method to unpack modules", () => { + const packedJoinPoint = pack.mock.results.pop().value; + expect(togglePoint).toHaveBeenCalledWith({ + joinPoint: packedJoinPoint, + featuresMap: expect.any(Map), + unpack + }); }); it("should return the outcome of the toggle point", () => { @@ -59,7 +74,8 @@ describe("pathSegmentToggleHandler", () => { expect(node.has(segment)).toBe(true); node = node.get(segment); } - expect(node).toBe(variantPathMap.get(key)); + expect(pack).toHaveBeenCalledWith(variantPathMap.get(key)); + expect(node).toBe(pack.mock.results.pop().value); } }); }); diff --git a/packages/webpack/src/toggleHandlers/pathSegmentToggleHandler.js b/packages/webpack/src/toggleHandlers/pathSegmentToggleHandler.js deleted file mode 100644 index 5c568d3..0000000 --- a/packages/webpack/src/toggleHandlers/pathSegmentToggleHandler.js +++ /dev/null @@ -1,34 +0,0 @@ -const buildTree = (map = new Map(), parts, value) => { - const [part, ...rest] = parts; - if (rest.length) { - map.set(part, buildTree(map.get(part), rest, value)); - } else { - map.set(part, value); - } - return map; -}; - -/** - * Path Segment Toggle Handler - * @memberof module:web-toggle-point-webpack - * @inner - * @param {object} options plugin options - * @param {function} options.togglePoint a method that chooses the appropriate module at runtime - * @param {module} options.joinPoint the join point module - * @param {Map} params.variantPathMap a Map of posix file paths, relative to the join point module, to variant modules - * @returns {function} A handler of join points injected by the plugin - */ -const pathSegmentToggleHandler = ({ - togglePoint, - joinPoint, - variantPathMap -}) => { - let featuresMap; - for (const [key, value] of variantPathMap) { - const parts = key.split("/").slice(0, -1).slice(2); - featuresMap = buildTree(featuresMap, parts, value); - } - return togglePoint(joinPoint, featuresMap); -}; - -export default pathSegmentToggleHandler; diff --git a/test/automation/docs/CHANGELOG.md b/test/automation/docs/CHANGELOG.md index cd2e02b..f165752 100644 --- a/test/automation/docs/CHANGELOG.md +++ b/test/automation/docs/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - ?? + +### Added + +- added `:ui` scripts, for those that want to watch their automation + +### Changed + +- updated Playwright to 1.52.0 + ## [0.1.5] - 2025-11-14 ### Changed diff --git a/test/automation/package.json b/test/automation/package.json index d9725e2..eca92e6 100644 --- a/test/automation/package.json +++ b/test/automation/package.json @@ -9,10 +9,13 @@ "test": "npm run test:next & npm run test:serve & npm run test:express", "pretest:next": "npm run install-dependencies", "test:next": "playwright test --config ../../examples/next/playwright.config.ts", + "test:next:ui": "npm run test:next -- --ui", "pretest:serve": "npm run install-dependencies", "test:serve": "playwright test --config ../../examples/serve/playwright.config.ts", - "pretest:express": "npm run install-dependencies && path-exists ../../examples/express/bin/server.cjs || npm run --prefix ../../examples/express build", - "test:express": "playwright test --config ../../examples/express/playwright.config.ts" + "test:serve:ui": "npm run test:serve -- --ui", + "pretest:express": "npm run install-dependencies && path-exists ../../examples/express/bin/server.mjs || npm run --prefix ../../examples/express build", + "test:express": "playwright test --config ../../examples/express/playwright.config.ts", + "test:express:ui": "npm run test:express -- --ui" }, "description": "", "devDependencies": {