diff --git a/packages/react-signals-transform/CHANGELOG.md b/packages/react-signals-transform/CHANGELOG.md new file mode 100644 index 0000000..ee96bc6 --- /dev/null +++ b/packages/react-signals-transform/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.1.0 (2026-03-15) + +### Features + +- add the React Signals transform plugin diff --git a/packages/react-signals-transform/README.md b/packages/react-signals-transform/README.md new file mode 100644 index 0000000..f5d285f --- /dev/null +++ b/packages/react-signals-transform/README.md @@ -0,0 +1,60 @@ +# @rolldown/plugin-react-signals-transform [![npm](https://img.shields.io/npm/v/@rolldown/plugin-react-signals-transform.svg)](https://npmx.dev/package/@rolldown/plugin-react-signals-transform) + +Rolldown plugin for the React Signals transform. + +It applies the Signals React transform during Rolldown builds with Rolldown's native magic string pipeline, so React components and hooks can automatically subscribe to signal reads without wiring Babel up manually. + +## Install + +```bash +pnpm add -D @rolldown/plugin-react-signals-transform +pnpm add react @preact/signals-react +``` + +## Usage + +```ts +import reactSignalsTransform from '@rolldown/plugin-react-signals-transform' + +export default { + plugins: [ + reactSignalsTransform({ + mode: 'auto', + }), + ], +} +``` + +## Options + +This plugin forwards the same options as `@preact/signals-react-transform`: + +- `mode` +- `importSource` +- `detectTransformedJSX` +- `experimental` + +Example: + +```ts +reactSignalsTransform({ + detectTransformedJSX: true, + experimental: { + debug: true, + }, +}) +``` + +## Notes + +- Run it before other JSX transforms. +- The generated code imports `useSignals` from `@preact/signals-react/runtime` by default. +- When your code is already compiled to `react/jsx-runtime` or `React.createElement`, enable `detectTransformedJSX`. + +## License + +MIT + +## Credits + +The transform logic is adapted from [`packages/react-transform`](https://github.com/preactjs/signals/tree/main/packages/react-transform) in the Preact Signals repository. The test cases are ported from the same package. diff --git a/packages/react-signals-transform/package.json b/packages/react-signals-transform/package.json new file mode 100644 index 0000000..d6fe5b7 --- /dev/null +++ b/packages/react-signals-transform/package.json @@ -0,0 +1,69 @@ +{ + "name": "@rolldown/plugin-react-signals-transform", + "version": "0.1.0", + "description": "Rolldown plugin for the React Signals transform", + "keywords": [ + "plugin", + "react", + "rolldown", + "rolldown-plugin", + "signals" + ], + "homepage": "https://github.com/rolldown/plugins/tree/main/packages/react-signals-transform#readme", + "bugs": { + "url": "https://github.com/rolldown/plugins/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rolldown/plugins.git", + "directory": "packages/react-signals-transform" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": "./dist/index.mjs", + "scripts": { + "dev": "tsdown --watch", + "build": "tsdown", + "test": "vitest --project react-signals-transform", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "rolldown-string": "^0.3.0" + }, + "devDependencies": { + "@preact/signals-core": "^1.12.1", + "@preact/signals-react": "^3.9.1", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "jsdom": "^26.1.0", + "prettier": "^3.6.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "rolldown": "^1.0.0-rc.9", + "vite": "^8.0.0" + }, + "peerDependencies": { + "@preact/signals-react": "^3.9.1", + "react": "^16.14.0 || 17.x || 18.x || 19.x", + "rolldown": "^1.0.0-rc.9", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "engines": { + "node": ">=22.12.0 || ^24.0.0" + }, + "compatiblePackages": { + "schemaVersion": 1, + "rollup": { + "type": "incompatible", + "reason": "Uses Rolldown-specific APIs" + } + } +} diff --git a/packages/react-signals-transform/src/index.ts b/packages/react-signals-transform/src/index.ts new file mode 100644 index 0000000..6abac24 --- /dev/null +++ b/packages/react-signals-transform/src/index.ts @@ -0,0 +1,1004 @@ +/* oxlint-disable */ +// @ts-nocheck + +import { withMagicString } from 'rolldown-string' +import type { Plugin } from 'rolldown' +import { parseSync } from 'rolldown/utils' +import type { ESTree } from 'rolldown/utils' +import type { ReactSignalsTransformPluginOptions } from './types.ts' + +export type { ReactSignalsTransformPluginOptions } from './types.ts' + +const optOutCommentIdentifier = /(^|\s)@no(Use|Track)Signals(\s|$)/ +const optInCommentIdentifier = /(^|\s)@(use|track)Signals(\s|$)/ +const defaultImportSource = '@preact/signals-react/runtime' +const defaultHookIdentifier = '_useSignals' +const effectIdentifier = '_effect' + +const UNMANAGED = '0' +const MANAGED_COMPONENT = '1' +const MANAGED_HOOK = '2' + +const signalCallNames = new Set(['signal', 'computed', 'useSignal', 'useComputed']) + +const jsxPackages = { + 'react/jsx-runtime': ['jsx', 'jsxs'], + 'react/jsx-dev-runtime': ['jsxDEV'], + react: ['createElement'], +} + +type FunctionLike = + | ESTree.FunctionDeclaration + | ESTree.FunctionExpression + | ESTree.ArrowFunctionExpression + +interface FunctionInfo { + node: FunctionLike + name: string | null + containsJSX: boolean + maybeUsesSignal: boolean +} + +function basename(filename: string | undefined): string | undefined { + return filename?.split(/[\\/]/).pop() +} + +function looksLikeJSX(code: string): boolean { + return /<>|<\/[A-Za-z]|<[A-Za-z]/.test(code) +} + +function getParseOptions(id: string, code: string) { + const cleanId = id.replace(/\?.*$/, '') + const isCommonJS = /(^|\W)require\s*\(|(^|\W)module\.exports\b|(^|\W)exports\./.test(code) + + let lang: 'js' | 'jsx' | 'ts' | 'tsx' = 'js' + if (cleanId.endsWith('.tsx')) { + lang = 'tsx' + } else if (cleanId.endsWith('.ts') || cleanId.endsWith('.mts') || cleanId.endsWith('.cts')) { + lang = looksLikeJSX(code) ? 'tsx' : 'ts' + } else if (cleanId.endsWith('.jsx')) { + lang = 'jsx' + } else if (looksLikeJSX(code)) { + lang = 'jsx' + } + + return { + isCommonJS, + lang, + sourceType: isCommonJS ? 'commonjs' : 'module', + } +} + +function isNode(value: unknown): value is ESTree.Node { + return ( + value != null && + typeof value === 'object' && + typeof Reflect.get(value, 'type') === 'string' && + typeof Reflect.get(value, 'start') === 'number' && + typeof Reflect.get(value, 'end') === 'number' + ) +} + +function walkNode( + node: ESTree.Node, + parent: ESTree.Node | null, + visit: (node: ESTree.Node, parent: ESTree.Node | null) => void, +): void { + visit(node, parent) + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) { + if (isNode(item)) { + walkNode(item, node, visit) + } + } + } else if (isNode(value)) { + walkNode(value, node, visit) + } + } +} + +function isFunctionLike(node: ESTree.Node): node is FunctionLike { + return ( + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression' + ) +} + +function getObjectPropertyKey(node: ESTree.Property): string | null { + if (node.key.type === 'Identifier') { + return node.key.name + } + + if (node.key.type === 'Literal' && typeof node.key.value === 'string') { + return node.key.value + } + + return null +} + +function getFunctionNodeName(node: FunctionLike): string | null { + if ( + (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression') && + node.id != null + ) { + return node.id.name + } + + return null +} + +function getAssignmentTargetName(node: ESTree.AssignmentExpression): string | null { + if (node.left.type === 'Identifier') { + return node.left.name + } + + if (node.left.type !== 'MemberExpression') { + return null + } + + const property = node.left.property + if (!node.left.computed && property.type === 'Identifier') { + return property.name + } + + if (property.type === 'Literal' && typeof property.value === 'string') { + return property.value + } + + return null +} + +function getFunctionNameFromParent( + node: ESTree.Node | null | undefined, + parentMap: Map, + filename: string | undefined, +): string | null { + if (node == null) { + return null + } + + if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') { + return node.id.name + } + + if (node.type === 'AssignmentExpression') { + return getAssignmentTargetName(node) + } + + if (node.type === 'Property') { + return getObjectPropertyKey(node) + } + + if (node.type === 'ExportDefaultDeclaration') { + return basename(filename) ?? null + } + + if (node.type === 'CallExpression') { + return getFunctionNameFromParent(parentMap.get(node), parentMap, filename) + } + + if (node.type === 'ParenthesizedExpression') { + return getFunctionNameFromParent(parentMap.get(node), parentMap, filename) + } + + return null +} + +function getFunctionName( + node: FunctionLike, + parentMap: Map, + filename: string | undefined, +): string | null { + return ( + getFunctionNodeName(node) ?? getFunctionNameFromParent(parentMap.get(node), parentMap, filename) + ) +} + +function isComponentName(name: string | null): boolean { + return name != null && /^[A-Z]/.test(name) +} + +function isCustomHookName(name: string | null): boolean { + return name != null && /^use[A-Z]/.test(name) +} + +function isHookCallbackFunction( + node: FunctionLike, + parentMap: Map, +): boolean { + const parent = parentMap.get(node) + return ( + parent?.type === 'CallExpression' && + parent.callee.type === 'Identifier' && + isCustomHookName(parent.callee.name) + ) +} + +function findParentComponentOrHook( + node: ESTree.Node, + parentMap: Map, + functionInfoMap: Map, +): FunctionInfo | null { + let current = parentMap.get(node) + + while (current != null) { + if (isFunctionLike(current)) { + const info = functionInfoMap.get(current) + if (info == null) { + return null + } + + if (isComponentName(info.name) || isCustomHookName(info.name)) { + return info + } + + if (isHookCallbackFunction(current, parentMap)) { + return null + } + } + + current = parentMap.get(current) + } + + return null +} + +function hasLeadingComment( + node: ESTree.Node, + comments: ESTree.Comment[], + code: string, + matcher: RegExp, +): boolean { + return comments.some((comment) => { + if (comment.end > node.start) { + return false + } + + const between = code.slice(comment.end, node.start) + return /^\s*$/.test(between) && matcher.test(comment.value) + }) +} + +function isOptedIntoSignalTracking( + node: ESTree.Node | null | undefined, + comments: ESTree.Comment[], + code: string, + parentMap: Map, +): boolean { + if (node == null) { + return false + } + + switch (node.type) { + case 'ArrowFunctionExpression': + case 'FunctionExpression': + case 'FunctionDeclaration': + case 'ObjectExpression': + case 'VariableDeclarator': + case 'VariableDeclaration': + case 'AssignmentExpression': + case 'CallExpression': + case 'ParenthesizedExpression': + return ( + hasLeadingComment(node, comments, code, optInCommentIdentifier) || + isOptedIntoSignalTracking(parentMap.get(node), comments, code, parentMap) + ) + + case 'ExportDefaultDeclaration': + case 'ExportNamedDeclaration': + case 'Property': + case 'ExpressionStatement': + return hasLeadingComment(node, comments, code, optInCommentIdentifier) + + default: + return false + } +} + +function isOptedOutOfSignalTracking( + node: ESTree.Node | null | undefined, + comments: ESTree.Comment[], + code: string, + parentMap: Map, +): boolean { + if (node == null) { + return false + } + + switch (node.type) { + case 'ArrowFunctionExpression': + case 'FunctionExpression': + case 'FunctionDeclaration': + case 'ObjectExpression': + case 'VariableDeclarator': + case 'VariableDeclaration': + case 'AssignmentExpression': + case 'CallExpression': + case 'ParenthesizedExpression': + return ( + hasLeadingComment(node, comments, code, optOutCommentIdentifier) || + isOptedOutOfSignalTracking(parentMap.get(node), comments, code, parentMap) + ) + + case 'ExportDefaultDeclaration': + case 'ExportNamedDeclaration': + case 'Property': + case 'ExpressionStatement': + return hasLeadingComment(node, comments, code, optOutCommentIdentifier) + + default: + return false + } +} + +function shouldTransform( + info: FunctionInfo, + options: ReactSignalsTransformPluginOptions, + comments: ESTree.Comment[], + code: string, + parentMap: Map, +): boolean { + const isComponentFunction = info.containsJSX && isComponentName(info.name) + + if (isOptedOutOfSignalTracking(info.node, comments, code, parentMap)) { + return false + } + + if (isOptedIntoSignalTracking(info.node, comments, code, parentMap)) { + return true + } + + if (options.mode === 'all') { + return isComponentFunction + } + + if (options.mode == null || options.mode === 'auto') { + return info.maybeUsesSignal && (isComponentFunction || isCustomHookName(info.name)) + } + + return false +} + +function isValueMemberExpression(node: ESTree.MemberExpression): boolean { + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name === 'value' + } + + return node.property.type === 'Literal' && node.property.value === 'value' +} + +function hasValuePropertyInPattern(node: ESTree.ObjectPattern): boolean { + return node.properties.some((property) => { + if (property.type !== 'Property') { + return false + } + + return property.key.type === 'Identifier' && property.key.name === 'value' + }) +} + +function isRequireCall(node: ESTree.Node | null | undefined, source: string): boolean { + return ( + node?.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments[0]?.type === 'Literal' && + node.arguments[0].value === source + ) +} + +function collectJSXAlternativeImports(program: ESTree.Program) { + const identifiers = new Set() + const objects = new Map() + + for (const statement of program.body) { + if (statement.type === 'ImportDeclaration') { + const jsxMethods = jsxPackages[statement.source.value] + if (jsxMethods == null) { + continue + } + + for (const specifier of statement.specifiers) { + if (specifier.type === 'ImportSpecifier') { + const importedName = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value + + if (jsxMethods.includes(importedName)) { + identifiers.add(specifier.local.name) + } + } else if ( + specifier.type === 'ImportDefaultSpecifier' || + specifier.type === 'ImportNamespaceSpecifier' + ) { + objects.set(specifier.local.name, jsxMethods) + } + } + + continue + } + + if (statement.type !== 'VariableDeclaration') { + continue + } + + for (const declarator of statement.declarations) { + if ( + !isRequireCall(declarator.init, 'react') && + !isRequireCall(declarator.init, 'react/jsx-runtime') && + !isRequireCall(declarator.init, 'react/jsx-dev-runtime') + ) { + continue + } + + const source = declarator.init.arguments[0].value + const jsxMethods = jsxPackages[source] + if (jsxMethods == null) { + continue + } + + if (declarator.id.type === 'Identifier') { + objects.set(declarator.id.name, jsxMethods) + } else if (declarator.id.type === 'ObjectPattern') { + for (const property of declarator.id.properties) { + if (property.type !== 'Property') { + continue + } + + const importedName = getObjectPropertyKey(property) + if (!jsxMethods.includes(importedName ?? '')) { + continue + } + + if (property.value.type === 'Identifier') { + identifiers.add(property.value.name) + } + } + } + } + } + + return { identifiers, objects } +} + +function isJSXAlternativeCall( + node: ESTree.CallExpression, + jsxIdentifiers: Set, + jsxObjects: Map, +): boolean { + const callee = node.callee + + if (callee.type === 'Identifier') { + return jsxIdentifiers.has(callee.name) + } + + if (callee.type !== 'MemberExpression' || callee.object.type !== 'Identifier') { + return false + } + + const allowedMethods = jsxObjects.get(callee.object.name) + if (allowedMethods == null) { + return false + } + + if (!callee.computed && callee.property.type === 'Identifier') { + return allowedMethods.includes(callee.property.name) + } + + return callee.property.type === 'Literal' && typeof callee.property.value === 'string' + ? allowedMethods.includes(callee.property.value) + : false +} + +function isSignalCall(node: ESTree.CallExpression): boolean { + return node.callee.type === 'Identifier' && signalCallNames.has(node.callee.name) +} + +function getVariableNameFromDeclarator( + node: ESTree.Node, + parentMap: Map, +): string | null { + let current = node + + while (current != null) { + if (current.type === 'VariableDeclarator' && current.id.type === 'Identifier') { + return current.id.name + } + + current = parentMap.get(current) + } + + return null +} + +function hasNameInOptions(node: ESTree.CallExpression): boolean { + if (node.arguments.length < 2) { + return false + } + + const optionsArgument = node.arguments[1] + if (optionsArgument.type !== 'ObjectExpression') { + return false + } + + return optionsArgument.properties.some((property) => { + if (property.type !== 'Property') { + return false + } + + if (property.key.type === 'Identifier') { + return property.key.name === 'name' + } + + return property.key.type === 'Literal' && property.key.value === 'name' + }) +} + +function createLineLookup(code: string) { + const lineStarts = [0] + for (let index = 0; index < code.length; index++) { + if (code[index] === '\n') { + lineStarts.push(index + 1) + } + } + + return (offset: number): number => { + let low = 0 + let high = lineStarts.length - 1 + + while (low <= high) { + const middle = Math.floor((low + high) / 2) + const start = lineStarts[middle] + const next = lineStarts[middle + 1] ?? Number.POSITIVE_INFINITY + + if (offset < start) { + high = middle - 1 + } else if (offset >= next) { + low = middle + 1 + } else { + return middle + 1 + } + } + + return lineStarts.length + } +} + +function hasTrailingComma(code: string, end: number): boolean { + let index = end - 2 + while (index >= 0 && /\s/.test(code[index])) { + index-- + } + return code[index] === ',' +} + +function createSignalNameLiteral( + variableName: string, + filename: string | undefined, + lineOf: (offset: number) => number, + offset: number, +): string { + if (filename == null) { + return JSON.stringify(variableName) + } + + const file = basename(filename) + if (file == null) { + return JSON.stringify(variableName) + } + + return JSON.stringify(`${variableName} (${file}:${lineOf(offset)})`) +} + +function injectSignalName( + s: Parameters>[1] | any, + code: string, + node: ESTree.CallExpression, + variableName: string, + filename: string | undefined, + lineOf: (offset: number) => number, +): void { + const nameLiteral = createSignalNameLiteral(variableName, filename, lineOf, node.start) + const objectLiteral = `{\n name: ${nameLiteral}\n}` + + if (node.arguments.length === 0) { + s.appendLeft(node.end - 1, `undefined, ${objectLiteral}`) + return + } + + if (node.arguments.length === 1) { + s.appendLeft(node.end - 1, `, ${objectLiteral}`) + return + } + + const optionsArgument = node.arguments[1] + if (optionsArgument.type === 'ObjectExpression') { + if (optionsArgument.properties.length === 0) { + s.appendLeft(optionsArgument.end - 1, `name: ${nameLiteral}`) + return + } + + const separator = hasTrailingComma(code, optionsArgument.end) ? ' ' : ', ' + s.appendLeft(optionsArgument.end - 1, `${separator}name: ${nameLiteral}`) + return + } + + s.update(optionsArgument.start, optionsArgument.end, objectLiteral) +} + +function extractBindingNames( + pattern: ESTree.ParamPattern | ESTree.BindingPattern, + names: string[], +) { + switch (pattern.type) { + case 'Identifier': + names.push(pattern.name) + break + + case 'ArrayPattern': + for (const element of pattern.elements) { + if (element != null) { + extractBindingNames(element, names) + } + } + break + + case 'ObjectPattern': + for (const property of pattern.properties) { + if (property.type === 'RestElement') { + extractBindingNames(property.argument, names) + } else { + extractBindingNames(property.value, names) + } + } + break + + case 'AssignmentPattern': + extractBindingNames(pattern.left, names) + break + + case 'RestElement': + extractBindingNames(pattern.argument, names) + break + } +} + +function collectTopLevelBindings(program: ESTree.Program): Set { + const names = new Set() + + for (const statement of program.body) { + if (statement.type === 'ImportDeclaration') { + for (const specifier of statement.specifiers) { + names.add(specifier.local.name) + } + continue + } + + if (statement.type === 'VariableDeclaration') { + for (const declaration of statement.declarations) { + const declarationNames: string[] = [] + extractBindingNames(declaration.id, declarationNames) + for (const name of declarationNames) { + names.add(name) + } + } + continue + } + + if ( + (statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') && + statement.id + ) { + names.add(statement.id.name) + } + } + + return names +} + +function createUniqueIdentifier(topLevelBindings: Set, base: string): string { + if (!topLevelBindings.has(base)) { + return base + } + + let index = 2 + while (topLevelBindings.has(`${base}${index}`)) { + index++ + } + return `${base}${index}` +} + +function findExistingHookBinding(program: ESTree.Program, importSource: string): string | null { + for (const statement of program.body) { + if (statement.type === 'ImportDeclaration' && statement.source.value === importSource) { + for (const specifier of statement.specifiers) { + if (specifier.type !== 'ImportSpecifier') { + continue + } + + const importedName = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value + + if (importedName === 'useSignals') { + return specifier.local.name + } + } + } + + if (statement.type !== 'VariableDeclaration') { + continue + } + + for (const declaration of statement.declarations) { + if ( + declaration.id.type === 'Identifier' && + declaration.init?.type === 'MemberExpression' && + declaration.init.object.type === 'CallExpression' && + isRequireCall(declaration.init.object, importSource) + ) { + const property = declaration.init.property + if ( + !declaration.init.computed && + property.type === 'Identifier' && + property.name === 'useSignals' + ) { + return declaration.id.name + } + } + + if ( + declaration.id.type !== 'ObjectPattern' || + !isRequireCall(declaration.init, importSource) + ) { + continue + } + + for (const property of declaration.id.properties) { + if (property.type !== 'Property') { + continue + } + + if (getObjectPropertyKey(property) !== 'useSignals') { + continue + } + + if (property.value.type === 'Identifier') { + return property.value.name + } + } + } + } + + return null +} + +function addHookImport( + s: Parameters>[1] | any, + program: ESTree.Program, + importSource: string, + hookIdentifier: string, + isCommonJS: boolean, +): void { + if (isCommonJS) { + s.prepend(`var ${hookIdentifier} = require(${JSON.stringify(importSource)}).useSignals\n`) + return + } + + const importLine = `import { useSignals as ${hookIdentifier} } from ${JSON.stringify(importSource)};\n` + let lastImport: ESTree.ImportDeclaration | null = null + + for (const statement of program.body) { + if (statement.type === 'ImportDeclaration') { + lastImport = statement + } + } + + if (lastImport != null) { + s.appendLeft(lastImport.end, `\n${importLine}`) + } else { + const leadingWhitespace = s.original.slice(0, program.start) + if (program.start > 0 && /^\s*$/.test(leadingWhitespace)) { + s.update(0, program.start, importLine) + } else { + s.prepend(importLine) + } + } +} + +function createUseSignalsCall( + hookIdentifier: string, + usage: string | null, + options: ReactSignalsTransformPluginOptions, + functionName: string | null, +): string { + const args: string[] = [] + + if (usage != null) { + args.push(usage) + } else if (options.experimental?.debug && functionName) { + args.push('undefined') + } + + if (options.experimental?.debug && functionName) { + args.push(JSON.stringify(functionName)) + } + + return `${hookIdentifier}(${args.join(', ')})` +} + +function transformFunction( + s: Parameters>[1] | any, + info: FunctionInfo, + hookIdentifier: string, + options: ReactSignalsTransformPluginOptions, +): void { + const isHook = isCustomHookName(info.name) + const isComponent = isComponentName(info.name) + const hookUsage = options.experimental?.noTryFinally + ? UNMANAGED + : isHook + ? MANAGED_HOOK + : isComponent + ? MANAGED_COMPONENT + : UNMANAGED + + const body = info.node.body + + if (hookUsage === UNMANAGED) { + const hookCall = createUseSignalsCall(hookIdentifier, null, options, info.name) + + if (body.type === 'BlockStatement') { + s.appendLeft(body.start + 1, `\n${hookCall};`) + return + } + + s.appendLeft(body.start, `{${hookCall};\nreturn `) + s.appendLeft(body.end, '\n}') + return + } + + const hookCall = createUseSignalsCall(hookIdentifier, hookUsage, options, info.name) + if (body.type === 'BlockStatement') { + s.appendLeft(body.start + 1, `\nvar ${effectIdentifier} = ${hookCall};\ntry {`) + s.appendLeft(body.end - 1, `\n} finally {\n${effectIdentifier}.f();\n}`) + return + } + + s.appendLeft(body.start, `{var ${effectIdentifier} = ${hookCall};\ntry {\nreturn `) + s.appendLeft(body.end, `;\n} finally {\n${effectIdentifier}.f();\n}\n}`) +} + +export default function reactSignalsTransform( + options: ReactSignalsTransformPluginOptions = {}, +): Plugin { + return { + name: '@rolldown/plugin-react-signals-transform', + // @ts-expect-error Vite-specific property + enforce: 'pre', + transform: { + filter: { + id: /\.[cm]?[jt]sx?(?:$|\?)/, + }, + handler: withMagicString(function (s, id) { + const parseOptions = getParseOptions(id, s.original) + const parsed = parseSync(id, s.original, { + lang: parseOptions.lang, + sourceType: parseOptions.sourceType, + }) + const program = parsed.program + const comments = parsed.comments ?? [] + const parentMap = new Map() + const functionInfoMap = new Map() + const signalCallsToName: Array<{ node: ESTree.CallExpression; variableName: string }> = [] + const lineOf = createLineLookup(s.original) + + walkNode(program, null, (node, parent) => { + parentMap.set(node, parent) + + if (isFunctionLike(node)) { + functionInfoMap.set(node, { + node, + name: null, + containsJSX: false, + maybeUsesSignal: false, + }) + } + }) + + for (const info of functionInfoMap.values()) { + info.name = getFunctionName(info.node, parentMap, id) + } + + const jsxAlternatives = options.detectTransformedJSX + ? collectJSXAlternativeImports(program) + : null + + walkNode(program, null, (node) => { + if (node.type === 'CallExpression') { + if ( + jsxAlternatives != null && + isJSXAlternativeCall(node, jsxAlternatives.identifiers, jsxAlternatives.objects) + ) { + const info = findParentComponentOrHook(node, parentMap, functionInfoMap) + if (info != null) { + info.containsJSX = true + } + } + + if (options.experimental?.debug && isSignalCall(node) && !hasNameInOptions(node)) { + const variableName = getVariableNameFromDeclarator(node, parentMap) + if (variableName != null) { + signalCallsToName.push({ node, variableName }) + } + } + } + + if (node.type === 'MemberExpression' && isValueMemberExpression(node)) { + const info = findParentComponentOrHook(node, parentMap, functionInfoMap) + if (info != null) { + info.maybeUsesSignal = true + } + } + + if (node.type === 'ObjectPattern' && hasValuePropertyInPattern(node)) { + const info = findParentComponentOrHook(node, parentMap, functionInfoMap) + if (info != null) { + info.maybeUsesSignal = true + } + } + + if (node.type === 'JSXElement' || node.type === 'JSXFragment') { + const info = findParentComponentOrHook(node, parentMap, functionInfoMap) + if (info != null) { + info.containsJSX = true + } + } + }) + + const functionsToTransform = Array.from(functionInfoMap.values()).filter((info) => + shouldTransform(info, options, comments, s.original, parentMap), + ) + + if (functionsToTransform.length === 0 && signalCallsToName.length === 0) { + return + } + + for (const { node, variableName } of signalCallsToName) { + injectSignalName(s, s.original, node, variableName, id, lineOf) + } + + let hookIdentifier = findExistingHookBinding( + program, + options.importSource ?? defaultImportSource, + ) + if (functionsToTransform.length > 0 && hookIdentifier == null) { + hookIdentifier = createUniqueIdentifier( + collectTopLevelBindings(program), + defaultHookIdentifier, + ) + addHookImport( + s, + program, + options.importSource ?? defaultImportSource, + hookIdentifier, + parseOptions.isCommonJS, + ) + } + + if (hookIdentifier == null) { + return + } + + for (const info of functionsToTransform) { + transformFunction(s, info, hookIdentifier, options) + } + }), + }, + } +} diff --git a/packages/react-signals-transform/src/types.ts b/packages/react-signals-transform/src/types.ts new file mode 100644 index 0000000..fd8643b --- /dev/null +++ b/packages/react-signals-transform/src/types.ts @@ -0,0 +1,9 @@ +export interface ReactSignalsTransformPluginOptions { + mode?: 'auto' | 'manual' | 'all' + importSource?: string + detectTransformedJSX?: boolean + experimental?: { + debug?: boolean + noTryFinally?: boolean + } +} diff --git a/packages/react-signals-transform/tests/helpers.ts b/packages/react-signals-transform/tests/helpers.ts new file mode 100644 index 0000000..21de9e1 --- /dev/null +++ b/packages/react-signals-transform/tests/helpers.ts @@ -0,0 +1,1378 @@ +/* oxlint-disable */ +// @ts-nocheck + +/** + * This file generates test cases for the transform. It generates a bunch of + * different components and then generates the source code for them. The + * generated source code is then used as the input for the transform. The test + * can then assert whether the transform should transform the code into the + * expected output or leave it untouched. + * + * Many of the language constructs generated here are to test the logic that + * finds the component name. For example, the transform should be able to find + * the component name even if the component is wrapped in a memo or forwardRef + * call. So we generate a bunch of components wrapped in those calls. + * + * We also generate constructs to test where users may place the comment to opt + * in or out of tracking signals. For example, the comment may be placed on the + * function declaration, the variable declaration, or the export statement. + * + * Some common abbreviations you may see in this file: + * - Comp: component + * - Exp: expression + * - Decl: declaration + * - Var: variable + * - Obj: object + * - Prop: property + */ + +// TODO: consider separating into a codeGenerators.ts file and a caseGenerators.ts file + +/** + * Interface representing the input and transformed output. A test may choose + * to use the transformed output or ignore it if the test is asserting the + * plugin does nothing + */ +interface InputOutput { + input: string + transformed: string +} + +export type CommentKind = 'opt-in' | 'opt-out' | undefined +type VariableKind = 'var' | 'let' | 'const' +type ParamsConfig = 0 | 1 | 2 | 3 | undefined + +type HookUsage = '' | '0' | '1' | '2' +interface ComponentConfig { + name?: string | undefined + body: string + params?: ParamsConfig + comment?: CommentKind + usage?: HookUsage +} + +interface FuncDeclComponent extends ComponentConfig { + type: 'FuncDeclComp' + name: string +} + +interface FuncDeclHook { + type: 'FuncDeclHook' + name: string + body: string + comment?: CommentKind + usage?: HookUsage +} + +interface FuncExpComponent extends ComponentConfig { + type: 'FuncExpComp' +} + +interface FuncExpHook { + type: 'FuncExpHook' + name?: string + body: string + usage?: HookUsage +} + +interface ArrowFuncComponent extends ComponentConfig { + type: 'ArrowComp' + return: 'statement' | 'expression' + name?: undefined +} + +interface ArrowFuncHook { + type: 'ArrowFuncHook' + return: 'statement' | 'expression' + body: string + usage?: HookUsage +} + +interface ObjMethodComponent extends ComponentConfig { + type: 'ObjectMethodComp' + name: string +} + +// TOOD: Add object method hook tests +// interface ObjMethodHook { +// type: "ObjectMethodHook"; +// name: string; +// body: string; +// } + +interface CallExp { + type: 'CallExp' + name: string + args: Array +} + +interface Variable { + type: 'Variable' + name: string + body: InputOutput + kind?: VariableKind + comment?: CommentKind + inlineComment?: CommentKind +} + +interface Assignment { + type: 'Assignment' + name: string + body: InputOutput + kind?: VariableKind + comment?: CommentKind +} + +interface MemberExpAssign { + type: 'MemberExpAssign' + property: string + body: InputOutput + comment?: CommentKind +} + +interface ObjectProperty { + type: 'ObjectProperty' + name: string + body: InputOutput + comment?: CommentKind +} + +interface ExportDefault { + type: 'ExportDefault' + body: InputOutput + comment?: CommentKind +} + +interface ExportNamed { + type: 'ExportNamed' + body: InputOutput + comment?: CommentKind +} + +interface NodeTypes { + FuncDeclComp: FuncDeclComponent + FuncDeclHook: FuncDeclHook + FuncExpComp: FuncExpComponent + FuncExpHook: FuncExpHook + ArrowComp: ArrowFuncComponent + ObjectMethodComp: ObjMethodComponent + ArrowFuncHook: ArrowFuncHook + CallExp: CallExp + ExportDefault: ExportDefault + ExportNamed: ExportNamed + Variable: Variable + Assignment: Assignment + MemberExpAssign: MemberExpAssign + ObjectProperty: ObjectProperty +} + +type Node = NodeTypes[keyof NodeTypes] +type ComponentNode = NodeTypes['FuncDeclComp' | 'FuncExpComp' | 'ArrowComp' | 'ObjectMethodComp'] + +type HookNode = NodeTypes['FuncDeclHook' | 'FuncExpHook' | 'ArrowFuncHook'] + +type Generators = { + [key in keyof NodeTypes]: (config: NodeTypes[key]) => InputOutput +} + +function transformComponent(config: ComponentNode): string { + const { type, body } = config + const addReturn = type === 'ArrowComp' && config.return === 'expression' + + if (config.usage === '' || config.usage === '0') { + return `_useSignals(${config.usage ?? ''}); + ${addReturn ? 'return ' : ''}${body}` + } else { + return `var _effect = _useSignals(${config.usage ?? '1'}); + try { + ${addReturn ? 'return ' : ''}${body} + } finally { + _effect.f(); + }` + } +} + +function transformHook(config: HookNode): string { + const { type, body } = config + const addReturn = type === 'ArrowFuncHook' && config.return === 'expression' + + if (config.usage === '' || config.usage === '0') { + return `_useSignals(${config.usage ?? ''}); + ${addReturn ? 'return ' : ''}${body}` + } else { + return `var _effect = _useSignals(${config.usage ?? '2'}); + try { + ${addReturn ? 'return ' : ''}${body} + } finally { + _effect.f(); + }` + } +} + +function generateParams(count?: ParamsConfig): string { + if (count == null || count === 0) return '' + if (count === 1) return 'props' + if (count === 2) return 'props, ref' + return Array.from({ length: count }, (_, i) => `arg${i}`).join(', ') +} + +function generateComment(comment?: CommentKind): string { + if (comment === 'opt-out') return '/* @noUseSignals */\n' + if (comment === 'opt-in') return '/* @useSignals */\n' + return '' +} + +const codeGenerators: Generators = { + FuncDeclComp(config) { + const params = generateParams(config.params) + const inputBody = config.body + const outputBody = transformComponent(config) + let comment = generateComment(config.comment) + return { + input: `${comment}function ${config.name}(${params}) {\n${inputBody}\n}`, + transformed: `${comment}function ${config.name}(${params}) {\n${outputBody}\n}`, + } + }, + FuncDeclHook(config) { + const inputBody = config.body + const outputBody = transformHook(config) + let comment = generateComment(config.comment) + return { + input: `${comment}function ${config.name}() {\n${inputBody}\n}`, + transformed: `${comment}function ${config.name}() {\n${outputBody}\n}`, + } + }, + FuncExpComp(config) { + const name = config.name ?? '' + const params = generateParams(config.params) + const inputBody = config.body + const outputBody = transformComponent(config) + return { + input: `(function ${name}(${params}) {\n${inputBody}\n})`, + transformed: `(function ${name}(${params}) {\n${outputBody}\n})`, + } + }, + FuncExpHook(config) { + const name = config.name ?? '' + const inputBody = config.body + const outputBody = transformHook(config) + return { + input: `(function ${name}() {\n${inputBody}\n})`, + transformed: `(function ${name}() {\n${outputBody}\n})`, + } + }, + ArrowComp(config) { + const params = generateParams(config.params) + const isExpBody = config.return === 'expression' + const inputBody = isExpBody ? config.body : `{\n${config.body}\n}` + const outputBody = transformComponent(config) + return { + input: `(${params}) => ${inputBody}`, + transformed: `(${params}) => {\n${outputBody}\n}`, + } + }, + ArrowFuncHook(config) { + const isExpBody = config.return === 'expression' + const inputBody = isExpBody ? config.body : `{\n${config.body}\n}` + const outputBody = transformHook(config) + return { + input: `() => ${inputBody}`, + transformed: `() => {\n${outputBody}\n}`, + } + }, + ObjectMethodComp(config) { + const params = generateParams(config.params) + const inputBody = config.body + const outputBody = transformComponent(config) + const comment = generateComment(config.comment) + return { + input: `var o = {\n${comment}${config.name}(${params}) {\n${inputBody}\n}\n};`, + transformed: `var o = {\n${comment}${config.name}(${params}) {\n${outputBody}\n}\n};`, + } + }, + CallExp(config) { + return { + input: `${config.name}(${config.args.map((arg) => arg.input).join(', ')})`, + transformed: `${config.name}(${config.args.map((arg) => arg.transformed).join(', ')})`, + } + }, + Variable(config) { + const kind = config.kind ?? 'const' + const comment = generateComment(config.comment) + const inlineComment = generateComment(config.inlineComment)?.trim() + return { + input: `${comment}${kind} ${config.name} = ${inlineComment}${config.body.input}`, + transformed: `${comment}${kind} ${config.name} = ${inlineComment}${config.body.transformed}`, + } + }, + Assignment(config) { + const kind = config.kind ?? 'let' + const comment = generateComment(config.comment) + return { + input: `${kind} ${config.name};\n ${comment}${config.name} = ${config.body.input}`, + transformed: `${kind} ${config.name};\n ${comment}${config.name} = ${config.body.transformed}`, + } + }, + MemberExpAssign(config) { + const comment = generateComment(config.comment) + const isComputed = config.property.startsWith('[') + const property = isComputed ? config.property : `.${config.property}` + return { + input: `${comment}obj.prop1${property} = ${config.body.input}`, + transformed: `${comment}obj.prop1${property} = ${config.body.transformed}`, + } + }, + ObjectProperty(config) { + const comment = generateComment(config.comment) + return { + input: `var o = {\n ${comment}${config.name}: ${config.body.input} \n}`, + transformed: `var o = {\n ${comment}${config.name}: ${config.body.transformed} \n}`, + } + }, + ExportDefault(config) { + const comment = generateComment(config.comment) + return { + input: `${comment}export default ${config.body.input}`, + transformed: `${comment}export default ${config.body.transformed}`, + } + }, + ExportNamed(config) { + const comment = generateComment(config.comment) + return { + input: `${comment}export ${config.body.input}`, + transformed: `${comment}export ${config.body.transformed}`, + } + }, +} + +function generateCode(config: Node): InputOutput { + return codeGenerators[config.type](config as any) +} + +export interface GeneratedCode extends InputOutput { + name: string +} + +interface CodeConfig { + /** Whether to output source code that auto should transform */ + auto: boolean + /** What kind of opt-in or opt-out to include if any */ + comment?: CommentKind + /** Name of the generated code (useful for test case titles) */ + name?: string + /** Number of parameters the component function should have */ + params?: ParamsConfig +} + +interface ComponentCodeConfig extends CodeConfig { + properInlineName?: boolean + usage?: HookUsage +} + +interface HookCodeConfig extends CodeConfig { + usage?: HookUsage +} + +interface VariableCodeConfig extends CodeConfig { + inlineComment?: CommentKind +} + +const codeTitle = (...parts: Array) => parts.filter(Boolean).join(' ') + +function expressionComponents(config: ComponentCodeConfig): GeneratedCode[] { + const { name: baseName, params, usage } = config + + let components: GeneratedCode[] + if (config.auto) { + components = [ + { + name: codeTitle(baseName, 'as function without inline name'), + ...generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + params, + usage, + }), + }, + { + name: codeTitle(baseName, 'as arrow function with statement body'), + ...generateCode({ + type: 'ArrowComp', + return: 'statement', + body: 'return
{signal.value}
', + params, + usage, + }), + }, + { + name: codeTitle(baseName, 'as arrow function with expression body'), + ...generateCode({ + type: 'ArrowComp', + return: 'expression', + body: '
{signal.value}
', + params, + usage, + }), + }, + ] + } else { + components = [ + { + name: codeTitle(baseName, 'as function with no JSX'), + ...generateCode({ + type: 'FuncExpComp', + body: 'return signal.value', + params, + usage, + }), + }, + { + name: codeTitle(baseName, 'as function with no signals'), + ...generateCode({ + type: 'FuncExpComp', + body: 'return
Hello World
', + params, + usage, + }), + }, + { + name: codeTitle(baseName, 'as arrow function with no JSX'), + ...generateCode({ + type: 'ArrowComp', + return: 'expression', + body: 'signal.value', + params, + usage, + }), + }, + { + name: codeTitle(baseName, 'as arrow function with no signals'), + ...generateCode({ + type: 'ArrowComp', + return: 'expression', + body: '
Hello World
', + params, + usage, + }), + }, + ] + } + + if ((config.properInlineName != null && !config.properInlineName) || !config.auto) { + components.push({ + name: codeTitle(baseName, 'as function with bad inline name'), + ...generateCode({ + type: 'FuncExpComp', + name: 'app', + body: 'return
{signal.value}
', + params, + usage: '', + }), + }) + } else { + components.push({ + name: codeTitle(baseName, 'as function with proper inline name'), + ...generateCode({ + type: 'FuncExpComp', + name: 'App', + body: 'return
{signal.value}
', + params, + usage, + }), + }) + } + + return components +} + +function withCallExpWrappers(config: ComponentCodeConfig): GeneratedCode[] { + const codeCases: GeneratedCode[] = [] + + // Simulate a component wrapped memo + const memoedComponents = expressionComponents({ ...config, params: 1 }) + for (let component of memoedComponents) { + codeCases.push({ + name: component.name + ' wrapped in memo', + ...generateCode({ + type: 'CallExp', + name: 'memo', + args: [component], + }), + }) + } + + // Simulate a component wrapped in forwardRef + const forwardRefComponents = expressionComponents({ ...config, params: 2 }) + for (let component of forwardRefComponents) { + codeCases.push({ + name: component.name + ' wrapped in forwardRef', + ...generateCode({ + type: 'CallExp', + name: 'forwardRef', + args: [component], + }), + }) + } + + // Simulate components wrapped in both memo and forwardRef + for (let component of forwardRefComponents) { + codeCases.push({ + name: component.name + ' wrapped in memo and forwardRef', + ...generateCode({ + type: 'CallExp', + name: 'memo', + args: [ + generateCode({ + type: 'CallExp', + name: 'forwardRef', + args: [component], + }), + ], + }), + }) + } + + return codeCases +} + +export function declarationComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, params, comment } = config + if (config.auto) { + return [ + { + name: codeTitle(baseName, 'with proper name, jsx, and signal usage'), + ...generateCode({ + type: 'FuncDeclComp', + name: 'App', + body: 'return <>{signal.value}', + params, + comment, + }), + }, + ] + } else { + return [ + { + name: codeTitle(baseName, 'with bad name'), + ...generateCode({ + type: 'FuncDeclComp', + name: 'app', + body: 'return
{signal.value}
', + params, + comment, + usage: '', + }), + }, + { + name: codeTitle(baseName, 'with no JSX'), + ...generateCode({ + type: 'FuncDeclComp', + name: 'App', + body: 'return signal.value', + params, + comment, + }), + }, + { + name: codeTitle(baseName, 'with no signals'), + ...generateCode({ + type: 'FuncDeclComp', + name: 'App', + body: 'return
Hello World
', + params, + comment, + }), + }, + ] + } +} + +export function objMethodComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, params, comment } = config + if (config.auto) { + return [ + { + name: codeTitle(baseName, 'with proper name, jsx, and signal usage'), + ...generateCode({ + type: 'ObjectMethodComp', + name: 'App', + body: 'return <>{signal.value}', + params, + comment, + }), + }, + { + name: codeTitle(baseName, 'with computed literal name, jsx, and signal usage'), + ...generateCode({ + type: 'ObjectMethodComp', + name: "['App']", + body: 'return <>{signal.value}', + params, + comment, + }), + }, + ] + } else { + return [ + { + name: codeTitle(baseName, 'with bad name'), + ...generateCode({ + type: 'ObjectMethodComp', + name: 'app', + body: 'return
{signal.value}
', + params, + comment, + usage: '', + }), + }, + { + name: codeTitle(baseName, 'with dynamic name'), + ...generateCode({ + type: 'ObjectMethodComp', + name: "['App' + '1']", + body: 'return
{signal.value}
', + params, + comment, + usage: '', + }), + }, + { + name: codeTitle(baseName, 'with no JSX'), + ...generateCode({ + type: 'ObjectMethodComp', + name: 'App', + body: 'return signal.value', + params, + comment, + }), + }, + { + name: codeTitle(baseName, 'with no signals'), + ...generateCode({ + type: 'ObjectMethodComp', + name: 'App', + body: 'return
Hello World
', + params, + comment, + }), + }, + ] + } +} + +export function variableComp(config: VariableCodeConfig): GeneratedCode[] { + const { name: baseName, comment, inlineComment } = config + const codeCases: GeneratedCode[] = [] + + const components = expressionComponents(config) + for (const c of components) { + codeCases.push({ + name: codeTitle(c.name), + ...generateCode({ + type: 'Variable', + name: 'VarComp', + body: c, + comment, + inlineComment, + }), + }) + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, `as function with bad variable name`), + ...generateCode({ + type: 'Variable', + name: 'render', + comment, + inlineComment, + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(baseName, `as arrow function with bad variable name`), + ...generateCode({ + type: 'Variable', + name: 'render', + comment, + inlineComment, + body: generateCode({ + type: 'ArrowComp', + return: 'expression', + body: '
{signal.value}
', + usage: '', + }), + }), + }) + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + usage: config.auto ? '1' : '', + properInlineName: config.auto, + }) + const suffix = config.auto ? '' : 'with bad variable name' + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: 'Variable', + name: config.auto ? 'VarComp' : 'render', + body: c, + comment, + inlineComment, + }), + }) + } + + return codeCases +} + +export function assignmentComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, comment } = config + const codeCases: GeneratedCode[] = [] + + const components = expressionComponents(config) + for (const c of components) { + codeCases.push({ + name: codeTitle(c.name), + ...generateCode({ + type: 'Assignment', + name: 'AssignComp', + body: c, + comment, + }), + }) + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, 'function component with bad variable name'), + ...generateCode({ + type: 'Assignment', + name: 'render', + comment, + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(baseName, 'arrow function with bad variable name'), + ...generateCode({ + type: 'Assignment', + name: 'render', + comment, + body: generateCode({ + type: 'ArrowComp', + return: 'expression', + body: '
{signal.value}
', + usage: '', + }), + }), + }) + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + usage: config.auto ? '1' : '', + properInlineName: config.auto, + }) + const suffix = config.auto ? '' : 'with bad variable name' + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: 'Assignment', + name: config.auto ? 'AssignComp' : 'render', + body: c, + comment, + }), + }) + } + + return codeCases +} + +export function objAssignComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, comment } = config + const codeCases: GeneratedCode[] = [] + + const components = expressionComponents(config) + for (const c of components) { + codeCases.push({ + name: codeTitle(c.name), + ...generateCode({ + type: 'MemberExpAssign', + property: 'Comp', + body: c, + comment, + }), + }) + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, 'function component with bad property name'), + ...generateCode({ + type: 'MemberExpAssign', + property: 'render', + comment, + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(baseName, 'arrow function with bad property name'), + ...generateCode({ + type: 'MemberExpAssign', + property: 'render', + comment, + body: generateCode({ + type: 'ArrowComp', + return: 'expression', + body: '
{signal.value}
', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(baseName, 'function component with bad computed property name'), + ...generateCode({ + type: 'MemberExpAssign', + property: "['render']", + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + comment, + }), + }) + + codeCases.push({ + name: codeTitle(baseName, 'function component with dynamic computed property name'), + ...generateCode({ + type: 'MemberExpAssign', + property: "['Comp' + '1']", + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + comment, + }), + }) + } else { + codeCases.push({ + name: codeTitle(baseName, 'function component with computed property name'), + ...generateCode({ + type: 'MemberExpAssign', + property: "['Comp']", + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + }), + comment, + }), + }) + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + usage: config.auto ? '1' : '', + properInlineName: config.auto, + }) + const suffix = config.auto ? '' : 'with bad variable name' + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: 'MemberExpAssign', + property: config.auto ? 'Comp' : 'render', + body: c, + comment, + }), + }) + } + + return codeCases +} + +export function objectPropertyComp(config: CodeConfig): GeneratedCode[] { + const { name: baseName, comment } = config + const codeCases: GeneratedCode[] = [] + + const components = expressionComponents(config) + for (const c of components) { + codeCases.push({ + name: c.name, + ...generateCode({ + type: 'ObjectProperty', + name: 'ObjComp', + body: c, + comment, + }), + }) + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(baseName, 'function component with bad property name'), + ...generateCode({ + type: 'ObjectProperty', + name: 'render_prop', + comment, + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(baseName, 'arrow function with bad property name'), + ...generateCode({ + type: 'ObjectProperty', + name: 'render_prop', + comment, + body: generateCode({ + type: 'ArrowComp', + return: 'expression', + body: '
{signal.value}
', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(baseName, 'function component with bad computed property name'), + ...generateCode({ + type: 'ObjectProperty', + name: "['render']", + comment, + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(baseName, 'function component with dynamic computed property name'), + ...generateCode({ + type: 'ObjectProperty', + name: "['Comp' + '1']", + comment, + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + usage: '', + }), + }), + }) + } else { + codeCases.push({ + name: codeTitle(baseName, 'function component with computed property name'), + ...generateCode({ + type: 'ObjectProperty', + name: "['Comp']", + comment, + body: generateCode({ + type: 'FuncExpComp', + body: 'return
{signal.value}
', + }), + }), + }) + } + + // With HoC wrappers, we are testing the logic to find the component name. So + // only generate tests where the function body is correct ("auto" is true) and + // the name is either correct or bad. + const hocComponents = withCallExpWrappers({ + ...config, + auto: true, + usage: config.auto ? '1' : '', + properInlineName: config.auto, + }) + const suffix = config.auto ? '' : 'with bad property name' + for (const c of hocComponents) { + codeCases.push({ + name: codeTitle(c.name, suffix), + ...generateCode({ + type: 'ObjectProperty', + name: config.auto ? 'ObjComp' : 'render_prop', + body: c, + comment, + }), + }) + } + + return codeCases +} + +export function exportDefaultComp(config: CodeConfig): GeneratedCode[] { + const { comment } = config + const codeCases: GeneratedCode[] = [] + + const usage = config.auto ? '1' : '' + const components = [ + ...declarationComp({ ...config, comment: undefined }), + ...expressionComponents({ ...config, usage }), + ...withCallExpWrappers({ ...config, usage }), + ] + + for (const c of components) { + codeCases.push({ + name: c.name + ' exported as default', + ...generateCode({ + type: 'ExportDefault', + body: c, + comment, + }), + }) + } + + return codeCases +} + +export function exportNamedComp(config: CodeConfig): GeneratedCode[] { + const { comment } = config + const codeCases: GeneratedCode[] = [] + + // `declarationComp` will put the comment on the function declaration, but in + // this case we want to put it on the export statement. + const funcComponents = declarationComp({ ...config, comment: undefined }) + for (const c of funcComponents) { + codeCases.push({ + name: `function declaration ${c.name}`, + ...generateCode({ + type: 'ExportNamed', + body: c, + comment, + }), + }) + } + + // `variableComp` will put the comment on the function declaration, but in + // this case we want to put it on the export statement. + const varComponents = variableComp({ ...config, comment: undefined }) + for (const c of varComponents) { + const name = c.name.replace(' variable ', ' exported ') + codeCases.push({ + name: `variable ${name}`, + ...generateCode({ + type: 'ExportNamed', + body: c, + comment, + }), + }) + } + + return codeCases +} + +function expressionHooks(config: HookCodeConfig): GeneratedCode[] { + const { name, usage } = config + if (config.auto) { + return [ + { + name: codeTitle(name, 'as function without inline name'), + ...generateCode({ + type: 'FuncExpHook', + body: 'return signal.value', + usage, + }), + }, + { + name: codeTitle(name, 'as function with proper inline name'), + ...generateCode({ + type: 'FuncExpHook', + name: 'useCustomHook', + body: 'return signal.value', + usage: '2', + }), + }, + { + name: codeTitle(name, 'as arrow function with with statement body'), + ...generateCode({ + type: 'ArrowFuncHook', + return: 'statement', + body: 'return signal.value', + usage, + }), + }, + { + name: codeTitle(name, 'as arrow function with with expression body'), + ...generateCode({ + type: 'ArrowFuncHook', + return: 'expression', + body: 'signal.value', + usage, + }), + }, + ] + } else { + return [ + { + name: codeTitle(name, 'as function with bad inline name'), + ...generateCode({ + type: 'FuncExpHook', + name: 'usecustomHook', + body: 'return signal.value', + usage: '', + }), + }, + { + name: codeTitle(name, 'as function with no signals'), + ...generateCode({ + type: 'FuncExpHook', + body: 'return useState(0)', + usage, + }), + }, + { + name: codeTitle(name, 'as arrow function with no signals'), + ...generateCode({ + type: 'ArrowFuncHook', + return: 'expression', + body: 'useState(0)', + usage, + }), + }, + ] + } +} + +export function declarationHooks(config: HookCodeConfig): GeneratedCode[] { + const { name, comment, usage } = config + if (config.auto) { + return [ + { + name: codeTitle(name, 'with proper name and signal usage'), + ...generateCode({ + type: 'FuncDeclHook', + name: 'useCustomHook', + comment, + body: 'return signal.value', + usage: '2', + }), + }, + ] + } else { + return [ + { + name: codeTitle(name, 'with bad name'), + ...generateCode({ + type: 'FuncDeclHook', + name: 'usecustomHook', + comment, + body: 'return signal.value', + usage: '', + }), + }, + { + name: codeTitle(name, 'with no signals'), + ...generateCode({ + type: 'FuncDeclHook', + name: 'useCustomHook', + comment, + body: 'return useState(0)', + usage, + }), + }, + ] + } +} + +export function variableHooks(config: VariableCodeConfig): GeneratedCode[] { + const { name, comment, inlineComment } = config + const codeCases: GeneratedCode[] = [] + + const hooks = expressionHooks(config) + for (const h of hooks) { + codeCases.push({ + name: codeTitle(h.name), + ...generateCode({ + type: 'Variable', + name: 'useCustomHook', + comment, + inlineComment, + body: h, + }), + }) + } + + if (!config.auto) { + codeCases.push({ + name: codeTitle(name, 'as function with bad variable name'), + ...generateCode({ + type: 'Variable', + name: 'usecustomHook', + comment, + inlineComment, + body: generateCode({ + type: 'FuncExpHook', + body: 'return signal.value', + usage: '', + }), + }), + }) + + codeCases.push({ + name: codeTitle(name, 'as arrow function with bad variable name'), + ...generateCode({ + type: 'Variable', + name: 'usecustomHook', + comment, + inlineComment, + body: generateCode({ + type: 'ArrowFuncHook', + return: 'expression', + body: 'signal.value', + usage: '', + }), + }), + }) + } + + return codeCases +} + +export function exportDefaultHooks(config: CodeConfig): GeneratedCode[] { + const { comment } = config + const codeCases: GeneratedCode[] = [] + + const usage = config.auto ? '2' : '' + const components = [ + ...declarationHooks({ ...config, comment: undefined }), + ...expressionHooks({ ...config, usage }), + ] + + for (const c of components) { + codeCases.push({ + name: c.name + ' exported as default', + ...generateCode({ + type: 'ExportDefault', + body: c, + comment, + }), + }) + } + + return codeCases +} + +export function exportNamedHooks(config: CodeConfig): GeneratedCode[] { + const { comment } = config + const codeCases: GeneratedCode[] = [] + + // `declarationHooks` will put the comment on the function declaration, but in + // this case we want to put it on the export statement. + const funcHooks = declarationHooks({ ...config, comment: undefined }) + for (const c of funcHooks) { + codeCases.push({ + name: `function declaration ${c.name}`, + ...generateCode({ + type: 'ExportNamed', + body: c, + comment, + }), + }) + } + + // `variableHooks` will put the comment on the function declaration, but in + // this case we want to put it on the export statement. + const varHooks = variableHooks({ ...config, comment: undefined }) + for (const c of varHooks) { + const name = c.name.replace(' variable ', ' exported ') + codeCases.push({ + name: `variable ${name}`, + ...generateCode({ + type: 'ExportNamed', + body: c, + comment, + }), + }) + } + + return codeCases +} + +// Command to use to debug the generated code +// ../../../../node_modules/.bin/tsc --target es2020 --module es2020 --moduleResolution node --esModuleInterop --outDir . helpers.ts; mv helpers.js helpers.mjs; node helpers.mjs +/* eslint-disable no-console */ +// @ts-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function debug() { + // @ts-ignore + const prettier = await import('prettier') + const format = (code: string) => prettier.format(code, { parser: 'babel' }) + console.log('generating...') + console.time('generated') + const codeCases: GeneratedCode[] = [ + // ...declarationComponents({ name: "transforms a", auto: true }), + // ...declarationComponents({ name: "does not transform a", auto: false }), + // + // ...expressionComponents({ name: "transforms a", auto: true }), + // ...expressionComponents({ name: "does not transform a", auto: false }), + // + // ...withCallExpWrappers({ name: "transforms a", auto: true }), + // ...withCallExpWrappers({ name: "does not transform a", auto: false }), + // + ...variableComp({ name: 'transforms a', auto: true }), + ...variableComp({ name: 'does not transform a', auto: false }), + + ...assignmentComp({ name: 'transforms a', auto: true }), + ...assignmentComp({ name: 'does not transform a', auto: false }), + + ...objectPropertyComp({ name: 'transforms a', auto: true }), + ...objectPropertyComp({ name: 'does not transform a', auto: false }), + + ...exportDefaultComp({ name: 'transforms a', auto: true }), + ...exportDefaultComp({ name: 'does not transform a', auto: false }), + + ...exportNamedComp({ name: 'transforms a', auto: true }), + ...exportNamedComp({ name: 'does not transform a', auto: false }), + ] + console.timeEnd('generated') + + for (const code of codeCases) { + console.log('='.repeat(80)) + console.log(code.name) + console.log('input:') + console.log(await format(code.input)) + console.log('transformed:') + console.log(await format(code.transformed)) + console.log() + } +} + +// debug(); diff --git a/packages/react-signals-transform/tests/runtime.test.ts b/packages/react-signals-transform/tests/runtime.test.ts new file mode 100644 index 0000000..112adc5 --- /dev/null +++ b/packages/react-signals-transform/tests/runtime.test.ts @@ -0,0 +1,571 @@ +/* oxlint-disable */ +// @ts-nocheck +// @vitest-environment jsdom + +import * as signalsCore from '@preact/signals-core' +import { batch, signal } from '@preact/signals-core' +import * as signalsRuntime from '@preact/signals-react/runtime' +import * as React from 'react' +import * as jsxDevRuntime from 'react/jsx-dev-runtime' +import * as jsxRuntime from 'react/jsx-runtime' +import { createRequire } from 'node:module' +import { runInNewContext } from 'node:vm' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { rolldown } from 'rolldown' +import { transform as rolldownTransform } from 'rolldown/utils' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import reactSignalsTransform, { type ReactSignalsTransformPluginOptions } from '../src/index.ts' + +const customSource = 'useSignals-custom-source' +const nodeRequire = createRequire(import.meta.url) +const disposeSymbol = Symbol.for('Symbol.dispose') +const disposableSignalsRuntime = { + ...signalsRuntime, + useSignals(...args: unknown[]) { + const value = signalsRuntime.useSignals(...args) + if ( + value != null && + typeof value === 'object' && + !(disposeSymbol in value) && + typeof value.f === 'function' + ) { + return { + ...value, + [Symbol.dispose]() { + value.f() + }, + [disposeSymbol]() { + value.f() + }, + } + } + + return value + }, +} +const modules: Record = { + '@preact/signals-core': signalsCore, + '@preact/signals-react/runtime': disposableSignalsRuntime, + react: React, + 'react/jsx-dev-runtime': jsxDevRuntime, + 'react/jsx-runtime': jsxRuntime, + [customSource]: disposableSignalsRuntime, +} + +function testRequire(name: string): unknown { + if (name in modules) { + return modules[name] + } + + return nodeRequire(name) +} + +async function createComponent( + code: string, + options: ReactSignalsTransformPluginOptions = {}, + filename = 'virtual:entry.tsx', +): Promise> { + let sourceCode = code + if (/\busing\s+/.test(sourceCode)) { + const transformed = await rolldownTransform(filename, sourceCode, { + jsx: 'preserve', + lang: filename.endsWith('.tsx') ? 'tsx' : filename.endsWith('.ts') ? 'ts' : 'jsx', + sourceType: 'module', + target: 'es2022', + }) + sourceCode = transformed.code + } + + const build = await rolldown({ + input: filename, + plugins: [ + { + name: 'virtual', + resolveId(id) { + if (id === filename) return id + return { id, external: true } + }, + load(id) { + if (id === filename) return sourceCode + }, + }, + reactSignalsTransform(options), + ], + }) + + const { output } = await build.generate({ format: 'cjs' }) + await build.close() + + const generatedCode = output[0].code + + const exports: Record = {} + const module = { exports } + runInNewContext(generatedCode, { + clearTimeout, + console, + exports, + globalThis, + module, + process, + require: testRequire, + setTimeout, + }) + return module.exports +} + +describe('react signals transform runtime', () => { + let scratch: HTMLDivElement + let root: ReturnType + + async function render(element: React.ReactElement) { + await act(async () => { + root.render(element) + }) + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + scratch = document.createElement('div') + document.body.appendChild(scratch) + root = createRoot(scratch) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + scratch.remove() + }) + + it('should rerender components when using signals as text', async () => { + const { App } = await createComponent(` + export function App({ name }) { + return
Hello {name}
; + } + `) + + const name = signal('John') + await render(React.createElement(App as any, { name })) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('should rerender components when signals they use change', async () => { + const { App } = await createComponent(` + export function App({ name }) { + return
Hello {name.value}
; + } + `) + + const name = signal('John') + await render(React.createElement(App as any, { name })) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('should rerender components with custom hooks that use signals', async () => { + const { App, name } = await createComponent(` + import { signal } from '@preact/signals-core'; + + export const name = signal('John'); + function useName() { + return name.value; + } + + export function App() { + const name = useName(); + return
Hello {name}
; + } + `) + + await render(React.createElement(App as any)) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('should rerender components with multiple custom hooks that use signals', async () => { + const { App, name, greeting } = await createComponent(` + import { signal } from '@preact/signals-core'; + + export const greeting = signal('Hello'); + function useGreeting() { + return greeting.value; + } + + export const name = signal('John'); + function useName() { + return name.value; + } + + export function App() { + const greeting = useGreeting(); + const name = useName(); + return
{greeting} {name}
; + } + `) + + await render(React.createElement(App as any)) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + greeting.value = 'Hi' + }) + expect(scratch.innerHTML).toBe('
Hi John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hi Jane
') + + await act(async () => { + batch(() => { + greeting.value = 'Hello' + name.value = 'John' + }) + }) + expect(scratch.innerHTML).toBe('
Hello John
') + }) + + it('should rerender components that use signals with multiple custom hooks that use signals', async () => { + const { App, name, greeting, punctuation } = await createComponent(` + import { signal } from '@preact/signals-core'; + + export const greeting = signal('Hello'); + function useGreeting() { + return greeting.value; + } + + export const name = signal('John'); + function useName() { + return name.value; + } + + export const punctuation = signal('!'); + export function App() { + const greeting = useGreeting(); + const name = useName(); + return
{greeting} {name}{punctuation.value}
; + } + `) + + await render(React.createElement(App as any)) + expect(scratch.innerHTML).toBe('
Hello John!
') + + await act(async () => { + greeting.value = 'Hi' + }) + expect(scratch.innerHTML).toBe('
Hi John!
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hi Jane!
') + + await act(async () => { + punctuation.value = '?' + }) + expect(scratch.innerHTML).toBe('
Hi Jane?
') + + await act(async () => { + batch(() => { + greeting.value = 'Hello' + name.value = 'John' + punctuation.value = '!' + }) + }) + expect(scratch.innerHTML).toBe('
Hello John!
') + }) + + it('should rerender components wrapped in memo', async () => { + const { MemoApp, name } = await createComponent(` + import { signal } from '@preact/signals-core'; + import { memo } from 'react'; + + export const name = signal('John'); + + function App({ name }) { + return
Hello {name.value}
; + } + + export const MemoApp = memo(App); + `) + + await render(React.createElement(MemoApp as any, { name })) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('should rerender components wrapped in memo inline', async () => { + const { MemoApp, name } = await createComponent(` + import { signal } from '@preact/signals-core'; + import { memo } from 'react'; + + export const name = signal('John'); + + export const MemoApp = memo(({ name }) => { + return
Hello {name.value}
; + }); + `) + + await render(React.createElement(MemoApp as any, { name })) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('should rerender components wrapped in forwardRef', async () => { + const { ForwardRefApp, name } = await createComponent(` + import { signal } from '@preact/signals-core'; + import { forwardRef } from 'react'; + + export const name = signal('John'); + + function App({ name }, ref) { + return
Hello {name.value}
; + } + + export const ForwardRefApp = forwardRef(App); + `) + + const ref = React.createRef() + await render(React.createElement(ForwardRefApp as any, { name, ref })) + expect(scratch.innerHTML).toBe('
Hello John
') + expect(ref.current).toBe(scratch.firstChild) + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + expect(ref.current).toBe(scratch.firstChild) + }) + + it('should rerender components wrapped in forwardRef inline', async () => { + const { ForwardRefApp, name } = await createComponent(` + import { signal } from '@preact/signals-core'; + import { forwardRef } from 'react'; + + export const name = signal('John'); + + export const ForwardRefApp = forwardRef(({ name }, ref) => { + return
Hello {name.value}
; + }); + `) + + const ref = React.createRef() + await render(React.createElement(ForwardRefApp as any, { name, ref })) + expect(scratch.innerHTML).toBe('
Hello John
') + expect(ref.current).toBe(scratch.firstChild) + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + expect(ref.current).toBe(scratch.firstChild) + }) + + it('should rerender components wrapped in forwardRef with memo', async () => { + const { MemoForwardRefApp, name } = await createComponent(` + import { signal } from '@preact/signals-core'; + import { memo, forwardRef } from 'react'; + + export const name = signal('John'); + + export const MemoForwardRefApp = memo(forwardRef(({ name }, ref) => { + return
Hello {name.value}
; + })); + `) + + const ref = React.createRef() + await render(React.createElement(MemoForwardRefApp as any, { name, ref })) + expect(scratch.innerHTML).toBe('
Hello John
') + expect(ref.current).toBe(scratch.firstChild) + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + expect(ref.current).toBe(scratch.firstChild) + }) + + it('should rerender registry-style declared components', async () => { + const { App, name, lang } = await createComponent(` + import { signal } from '@preact/signals-core'; + import { memo } from 'react'; + + const Greeting = { + English: memo(({ name }) =>
Hello {name.value}
), + ['Espanol']: memo(({ name }) =>
Hola {name.value}
), + }; + + export const name = signal('John'); + export const lang = signal('English'); + + export function App() { + const Component = Greeting[lang.value]; + return ; + } + `) + + await render(React.createElement(App as any)) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + + await act(async () => { + lang.value = 'Espanol' + }) + expect(scratch.innerHTML).toBe('
Hola Jane
') + }) + + it('should transform components authored inside a test body', async () => { + const { name, App } = await createComponent(` + import { signal } from '@preact/signals-core'; + + export const name = signal('John'); + export let App; + + const it = (name, fn) => fn(); + + it('should work', () => { + App = () => { + return
Hello {name.value}
; + }; + }); + `) + + await render(React.createElement(App as any)) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('should work when an ambiguous function is manually transformed and used as a hook', async () => { + const { App, greeting, name } = await createComponent(` + import { signal } from '@preact/signals-core'; + + export const greeting = signal('Hello'); + export const name = signal('John'); + + /** @useSignals */ + function usename() { + return name.value; + } + + export function App() { + const name = usename(); + return
{greeting.value} {name}
; + } + `) + + await render(React.createElement(App as any)) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + greeting.value = 'Hi' + }) + expect(scratch.innerHTML).toBe('
Hi John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hi Jane
') + + await act(async () => { + batch(() => { + greeting.value = 'Hello' + name.value = 'John' + }) + }) + expect(scratch.innerHTML).toBe('
Hello John
') + }) + + it('loads useSignals from a custom source', async () => { + const { App } = await createComponent( + ` + export function App({ name }) { + return
Hello {name.value}
; + } + `, + { importSource: customSource }, + ) + + const name = signal('John') + await render(React.createElement(App as any, { name })) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('works with the using keyword', async () => { + const { App } = await createComponent( + ` + import { useSignals } from '@preact/signals-react/runtime'; + + export function App({ name }) { + using _ = useSignals(); + return
Hello {name.value}
; + } + `, + { mode: 'manual' }, + ) + + const name = signal('John') + await render(React.createElement(App as any, { name })) + expect(scratch.innerHTML).toBe('
Hello John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
') + }) + + it('should transform components created by Array.map that use signals', async () => { + const { App } = await createComponent(` + export function App({ name }) { + const greetings = ['Hello', 'Goodbye']; + + const children = greetings.map((greeting) =>
{greeting} {name.value}
); + + return
{children}
; + } + `) + + const name = signal('John') + await render(React.createElement(App as any, { name })) + expect(scratch.innerHTML).toBe('
Hello John
Goodbye John
') + + await act(async () => { + name.value = 'Jane' + }) + expect(scratch.innerHTML).toBe('
Hello Jane
Goodbye Jane
') + }) +}) diff --git a/packages/react-signals-transform/tests/transform.test.ts b/packages/react-signals-transform/tests/transform.test.ts new file mode 100644 index 0000000..bbcf61a --- /dev/null +++ b/packages/react-signals-transform/tests/transform.test.ts @@ -0,0 +1,1327 @@ +// @ts-nocheck + +import prettier from 'prettier' +import signalsTransform, { type ReactSignalsTransformPluginOptions } from '../src/index.ts' +import { + CommentKind, + GeneratedCode, + assignmentComp, + objAssignComp, + declarationComp, + declarationHooks, + exportDefaultComp, + exportDefaultHooks, + exportNamedComp, + exportNamedHooks, + objectPropertyComp, + variableComp, + objMethodComp, + variableHooks, +} from './helpers' +import { it, describe, expect } from 'vitest' + +// Guidance for Debugging Generated Tests +// =============================== +// +// To help interactively debug a specific test case, add the test ids of the +// test cases you want to debug to the `debugTestIds` array, e.g. (["258", +// "259"]). Set to true to debug all tests. Set to false to skip all generated tests. +// +// The `debugger` statement in `runTestCases` will then trigger for the test case +// specified in the DEBUG_TEST_IDS. Follow the guide at https://vitest.dev/guide/debugging for +// instructions on debugging Vitest tests in your environment. +const DEBUG_TEST_IDS: string[] | boolean = [] + +const format = (code: string) => prettier.format(code, { parser: 'babel' }) + +function normalizeTransformedCode(code: string): string { + return code + .replace( + /^(import \{ useSignals as _useSignals \} from [^\n]+;|var _useSignals = require\([^\n]+\)\.useSignals;?)\n\n/m, + '$1\n', + ) + .replace(/\{\n\s+children: ([^\n]+),?\n\s*\}/g, '{ children: $1 }') + .replace(/\{\n\s+name: ([^,\n]+),?\n\s*\}/g, '{ name: $1 }') + .replace( + /\{\n\s+name: ([^,\n]+),\n\s+watched: \(\) => \{\},?\n\s*\}/g, + '{ name: $1, watched: () => {} }', + ) +} + +async function transformCode( + code: string, + options?: ReactSignalsTransformPluginOptions, + filename = 'virtual:entry.jsx', +): Promise { + const plugin = signalsTransform(options) + const handler = plugin.transform?.handler + if (handler == null) { + return code + } + + const result = await handler.call({}, code, filename, undefined) + + if (result == null) { + return code + } + + return typeof result === 'string' ? result : result.code +} + +async function runTest( + input: string, + expected: string, + options: ReactSignalsTransformPluginOptions = { mode: 'auto' }, + filename?: string, + _cjs?: boolean, +) { + const output = await transformCode(input, options, filename) + expect(await format(normalizeTransformedCode(output))).to.equal( + await format(normalizeTransformedCode(expected)), + ) +} + +interface TestCaseConfig { + /** Whether to use components whose body contains valid code auto mode would transform (true) or not (false) */ + useValidAutoMode: boolean + /** Whether to assert that the plugin transforms the code (true) or not (false) */ + expectTransformed: boolean + /** What kind of opt-in or opt-out to include if any */ + comment?: CommentKind + /** Options to pass to the babel plugin */ + options: ReactSignalsTransformPluginOptions + /** The filename to run the transform under */ + filename?: string +} + +let testCount = 0 +const getTestId = () => (testCount++).toString().padStart(3, '0') + +function runTestCases(config: TestCaseConfig, testCases: GeneratedCode[]) { + testCases = testCases.toSorted((a, b) => (a.name < b.name ? -1 : 1)) + + for (const testCase of testCases) { + let testId = getTestId() + + // Only run tests in debugTestIds + if ( + DEBUG_TEST_IDS === false || + (Array.isArray(DEBUG_TEST_IDS) && + DEBUG_TEST_IDS.length > 0 && + !DEBUG_TEST_IDS.includes(testId)) + ) { + continue + } + + it(`(${testId}) ${testCase.name}`, async () => { + if (DEBUG_TEST_IDS === true || DEBUG_TEST_IDS.includes(testId)) { + console.log('input:', testCase.input.replace(/\s+/g, ' ')) // eslint-disable-line no-console + debugger // eslint-disable-line no-debugger + } + + const input = await format(testCase.input) + const transformed = await format(testCase.transformed) + + let expected = '' + if (config.expectTransformed) { + expected += 'import { useSignals as _useSignals } from "@preact/signals-react/runtime";\n' + expected += transformed + } else { + expected = input + } + + await runTest(input, expected, config.options, config.filename) + }) + } +} + +function runGeneratedComponentTestCases(config: TestCaseConfig): void { + const codeConfig = { auto: config.useValidAutoMode, comment: config.comment } + config = { + ...config, + filename: config.useValidAutoMode ? '/path/to/Component.js' : 'C:\\path\\to\\lowercase.js', + } + + // e.g. function C() {} + describe('function components', () => { + runTestCases(config, declarationComp(codeConfig)) + }) + + // e.g. const C = () => {}; + describe('variable declared components', () => { + runTestCases(config, variableComp(codeConfig)) + }) + + if (config.comment !== undefined) { + // e.g. const C = () => {}; + describe('variable declared components (inline comment)', () => { + runTestCases( + config, + variableComp({ + ...codeConfig, + comment: undefined, + inlineComment: config.comment, + }), + ) + }) + } + + describe('object method components', () => { + runTestCases(config, objMethodComp(codeConfig)) + }) + + // e.g. C = () => {}; + describe('assigned to variable components', () => { + runTestCases(config, assignmentComp(codeConfig)) + }) + + // e.g. obj.C = () => {}; + describe('assigned to object property components', () => { + runTestCases(config, objAssignComp(codeConfig)) + }) + + // e.g. const obj = { C: () => {} }; + describe('object property components', () => { + runTestCases(config, objectPropertyComp(codeConfig)) + }) + + // e.g. export default () => {}; + describe(`default exported components`, () => { + runTestCases(config, exportDefaultComp(codeConfig)) + }) + + // e.g. export function C() {} + describe('named exported components', () => { + runTestCases(config, exportNamedComp(codeConfig)) + }) +} + +function runGeneratedHookTestCases(config: TestCaseConfig): void { + const codeConfig = { auto: config.useValidAutoMode, comment: config.comment } + config = { + ...config, + filename: config.useValidAutoMode + ? '/path/to/useCustomHook.js' + : 'C:\\path\\to\\usecustomHook.js', + } + + // e.g. function useCustomHook() {} + describe('function hooks', () => { + runTestCases(config, declarationHooks(codeConfig)) + }) + + // e.g. const useCustomHook = () => {} + describe('variable declared hooks', () => { + runTestCases(config, variableHooks(codeConfig)) + }) + + // e.g. export default () => {} + describe('default exported hooks', () => { + runTestCases(config, exportDefaultHooks(codeConfig)) + }) + + // e.g. export function useCustomHook() {} + describe('named exported hooks', () => { + runTestCases(config, exportNamedHooks(codeConfig)) + }) +} + +function runGeneratedTestCases(config: TestCaseConfig): void { + runGeneratedComponentTestCases(config) + runGeneratedHookTestCases(config) +} + +describe('React Signals Babel Transform', () => { + describe('auto mode transforms', () => { + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: true, + options: { mode: 'auto' }, + }) + + it('detects destructuring patterns with value property', async () => { + const inputCode = ` + function MyComponent(props) { + const { value: signalValue } = props.signal; + return
{signalValue}
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent(props) { + var _effect = _useSignals(1); + try { + const { value: signalValue } = props.signal; + return
{signalValue}
; + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput) + }) + + it('detects nested destructuring patterns with value property', async () => { + // Test case 1: Simple nested destructuring + const inputCode1 = ` + function MyComponent(props) { + const { signal: { value } } = props; + return
{value}
; + } + ` + + const expectedOutput1 = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent(props) { + var _effect = _useSignals(1); + try { + const { signal: { value } } = props; + return
{value}
; + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode1, expectedOutput1) + + // Test case 2: Deeply nested destructuring + const inputCode2 = ` + function MyComponent(props) { + const { data: { signal: { value: signalValue } } } = props; + return
{signalValue}
; + } + ` + + const expectedOutput2 = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent(props) { + var _effect = _useSignals(1); + try { + const { data: { signal: { value: signalValue } } } = props; + return
{signalValue}
; + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode2, expectedOutput2) + + // Test case 3: Multiple value properties at different levels + const inputCode3 = ` + function MyComponent(props) { + const { value: outerValue, signal: { value: innerValue } } = props; + return
{outerValue} {innerValue}
; + } + ` + + const expectedOutput3 = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent(props) { + var _effect = _useSignals(1); + try { + const { value: outerValue, signal: { value: innerValue } } = props; + return
{outerValue} {innerValue}
; + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode3, expectedOutput3) + }) + + it('signal access in nested functions', async () => { + const inputCode = ` + function MyComponent(props) { + return props.listSignal.value.map(function iteration(x) { + return
{x}
; + }); + }; + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent(props) { + var _effect = _useSignals(1); + try { + return props.listSignal.value.map(function iteration(x) { + return
{x}
; + }); + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput) + }) + }) + + describe("auto mode doesn't transform", () => { + it('should not leak JSX detection outside of component scope', async () => { + const inputCode = ` + function wrapper() { + function Component() { + return
Hello
; + } + const CountModel = createModel(() => ({ + count: signal(0), + increment() { + this.count.value++; + }, + })); + } + ` + + const expectedOutput = inputCode + + await runTest(inputCode, expectedOutput) + }) + + it('should not leak JSX detection outside of non-components', async () => { + const inputCode = ` + describe("suite", () => { + it("test 1", () => { + render(); + }); + it("test 2", () => { + const CountModel = () => signal.value; + function Counter() { + return
Hello2
; + } + render(); + }); + }); + ` + + const expectedOutput = inputCode + + await runTest(inputCode, expectedOutput) + }) + + it('useEffect callbacks that use signals', async () => { + const inputCode = ` + function App() { + useEffect(() => { + signal.value = Hi; + }, []); + return
Hello World
; + } + ` + + const expectedOutput = inputCode + await runTest(inputCode, expectedOutput) + }) + + runGeneratedTestCases({ + useValidAutoMode: false, + expectTransformed: false, + options: { mode: 'auto' }, + }) + }) + + describe('auto mode supports opting out of transforming', () => { + it('opt-out comment overrides opt-in comment', async () => { + const inputCode = ` + /** + * @noUseSignals + * @useSignals + */ + function MyComponent() { + return
{signal.value}
; + }; + ` + + const expectedOutput = inputCode + + await runTest(inputCode, expectedOutput, { mode: 'auto' }) + }) + + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: false, + comment: 'opt-out', + options: { mode: 'auto' }, + }) + }) + + describe('auto mode supports opting into transformation', () => { + runGeneratedTestCases({ + useValidAutoMode: false, + expectTransformed: true, + comment: 'opt-in', + options: { mode: 'auto' }, + }) + }) + + describe("manual mode doesn't transform anything by default", () => { + it('useEffect callbacks that use signals', async () => { + const inputCode = ` + function App() { + useEffect(() => { + signal.value = Hi; + }, []); + return
Hello World
; + } + ` + + const expectedOutput = inputCode + await runTest(inputCode, expectedOutput) + }) + + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: false, + options: { mode: 'manual' }, + }) + }) + + describe('manual mode opts into transforming', () => { + it('opt-out comment overrides opt-in comment', async () => { + const inputCode = ` + /** + * @noUseSignals + * @useSignals + */ + function MyComponent() { + return
{signal.value}
; + }; + ` + + const expectedOutput = inputCode + + await runTest(inputCode, expectedOutput, { mode: 'auto' }) + }) + + runGeneratedTestCases({ + useValidAutoMode: true, + expectTransformed: true, + comment: 'opt-in', + options: { mode: 'manual' }, + }) + }) +}) + +describe('React Signals Babel Transform', () => { + // TODO: Figure out what to do with the following + + describe('all mode transformations', () => { + it('should not leak JSX detection outside of component scope', async () => { + const inputCode = ` + function wrapper() { + function Component() { + return
Hello
; + } + const CountModel = createModel(() => ({ + count: signal(0), + increment() { + this.count.value++; + }, + })); + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function wrapper() { + function Component() { + var _effect = _useSignals(1); + try { + return
Hello
; + } finally { + _effect.f(); + } + } + const CountModel = createModel(() => ({ + count: signal(0), + increment() { + this.count.value++; + }, + })); + } + ` + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + + it('should not leak JSX detection outside of non-components', async () => { + const inputCode = ` + describe("suite", () => { + it("test 1", () => { + render(); + }); + it("test 2", () => { + const CountModel = () => signal.value; + function Counter() { + return
Hello2
; + } + render(); + }); + }); + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + describe("suite", () => { + it("test 1", () => { + render(); + }); + it("test 2", () => { + const CountModel = () => signal.value; + function Counter() { + var _effect = _useSignals(1); + try { + return
Hello2
; + } finally { + _effect.f(); + } + } + render(); + }); + }); + ` + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + + it('skips transforming arrow function component with leading opt-out JSDoc comment before variable declaration', async () => { + const inputCode = ` + /** @noUseSignals */ + const MyComponent = () => { + return
{signal.value}
; + }; + ` + + const expectedOutput = inputCode + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + + it('skips transforming function declaration components with leading opt-out JSDoc comment', async () => { + const inputCode = ` + /** @noUseSignals */ + function MyComponent() { + return
{signal.value}
; + } + ` + + const expectedOutput = inputCode + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + + it("transforms function declaration component that doesn't use signals", async () => { + const inputCode = ` + function MyComponent() { + return
Hello World
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1); + try { + return
Hello World
; + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + + it('transforms require syntax', async () => { + const inputCode = ` + const react = require("react"); + function MyComponent() { + return
Hello World
; + } + ` + + const expectedOutput = ` + var _useSignals = require("@preact/signals-react/runtime").useSignals + const react = require("react"); + function MyComponent() { + var _effect = _useSignals(1); + try { + return
Hello World
; + } finally { + _effect.f(); + } + } + ` + await runTest(inputCode, expectedOutput, { mode: 'all' }, undefined, true) + }) + + it("transforms arrow function component with return statement that doesn't use signals", async () => { + const inputCode = ` + const MyComponent = () => { + return
Hello World
; + }; + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + const MyComponent = () => { + var _effect = _useSignals(1); + try { + return
Hello World
; + } finally { + _effect.f(); + } + }; + ` + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + + it('transforms function declaration component that uses signals', async () => { + const inputCode = ` + function MyComponent() { + signal.value; + return
Hello World
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return
Hello World
; + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + + it('transforms arrow function component with return statement that uses signals', async () => { + const inputCode = ` + const MyComponent = () => { + signal.value; + return
Hello World
; + }; + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + const MyComponent = () => { + var _effect = _useSignals(1); + try { + signal.value; + return
Hello World
; + } finally { + _effect.f(); + } + }; + ` + + await runTest(inputCode, expectedOutput, { mode: 'all' }) + }) + }) + + describe('noTryFinally option', () => { + it('prepends arrow function component with useSignals call', async () => { + const inputCode = ` + const MyComponent = () => { + signal.value; + return
Hello World
; + }; + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + const MyComponent = () => { + _useSignals(); + signal.value; + return
Hello World
; + }; + ` + + await runTest(inputCode, expectedOutput, { + experimental: { noTryFinally: true }, + }) + }) + + it('prepends arrow function component with useSignals call', async () => { + const inputCode = ` + const MyComponent = () =>
{name.value}
; + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + const MyComponent = () => { + _useSignals(); + return
{name.value}
; + }; + ` + + await runTest(inputCode, expectedOutput, { + experimental: { noTryFinally: true }, + }) + }) + + it('prepends function declaration components with useSignals call', async () => { + const inputCode = ` + function MyComponent() { + signal.value; + return
Hello World
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + _useSignals(); + signal.value; + return
Hello World
; + } + ` + + await runTest(inputCode, expectedOutput, { + experimental: { noTryFinally: true }, + }) + }) + + it('prepends function expression components with useSignals call', async () => { + const inputCode = ` + const MyComponent = function () { + signal.value; + return
Hello World
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + const MyComponent = function () { + _useSignals(); + signal.value; + return
Hello World
; + }; + ` + + await runTest(inputCode, expectedOutput, { + experimental: { noTryFinally: true }, + }) + }) + + it('prepends custom hook function declarations with useSignals call', async () => { + const inputCode = ` + function useCustomHook() { + signal.value; + return useState(0); + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function useCustomHook() { + _useSignals(); + signal.value; + return useState(0); + } + ` + + await runTest(inputCode, expectedOutput, { + experimental: { noTryFinally: true }, + }) + }) + + it('recursively propogates `.value` reads to parent component', async () => { + const inputCode = ` + function MyComponent() { + return
{new Array(20).fill(null).map(() => signal.value)}
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + _useSignals(); + return
{new Array(20).fill(null).map(() => signal.value)}
; + } + ` + + await runTest(inputCode, expectedOutput, { + experimental: { noTryFinally: true }, + }) + }) + }) + + describe('importSource option', () => { + it('imports useSignals from custom source', async () => { + const inputCode = ` + const MyComponent = () => { + signal.value; + return
Hello World
; + }; + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "custom-source"; + const MyComponent = () => { + var _effect = _useSignals(1); + try { + signal.value; + return
Hello World
; + } finally { + _effect.f(); + } + }; + ` + + await runTest(inputCode, expectedOutput, { + importSource: 'custom-source', + }) + }) + }) + + describe('scope tracking', () => { + it('adds an import declaration and usage for useSignals', async () => { + const output = await transformCode( + ` + const MyComponent = () => { + signal.value; + return
Hello World
; + }; + `, + { mode: 'auto' }, + 'Component.jsx', + ) + + expect(output).toContain( + `import { useSignals as _useSignals } from "@preact/signals-react/runtime";`, + ) + expect(output).toContain('_useSignals(1)') + }) + }) + + describe('signal naming', () => { + const DEBUG_OPTIONS = { mode: 'auto', experimental: { debug: true } } + + const runDebugTest = async (inputCode: string, expectedOutput: string, fileName: string) => { + // @ts-expect-error + await runTest(inputCode, expectedOutput, DEBUG_OPTIONS, fileName) + } + + it('injects names for signal calls', async () => { + const inputCode = ` + function MyComponent() { + const count = signal(0); + const double = computed(() => count.value * 2); + return
{double.value}
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1, "MyComponent"); + try { + const count = signal(0, { + name: "count (Component.js:3)", + }); + const double = computed(() => count.value * 2, { + name: "double (Component.js:4)", + }); + return
{double.value}
; + } finally { + _effect.f(); + } + } + ` + + await runDebugTest(inputCode, expectedOutput, 'Component.js') + }) + + it('injects names for useSignal calls', async () => { + const inputCode = ` + function MyComponent() { + const count = useSignal(0); + const message = useSignal("hello"); + return
{count.value} {message.value}
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1, "MyComponent"); + try { + const count = useSignal(0, { + name: "count (Component.js:3)", + }); + const message = useSignal("hello", { + name: "message (Component.js:4)", + }); + return
{count.value} {message.value}
; + } finally { + _effect.f(); + } + } + ` + + await runDebugTest(inputCode, expectedOutput, 'Component.js') + }) + + it("doesn't inject names when already provided", async () => { + const inputCode = ` + function MyComponent() { + const count = signal(0, { name: "myCounter" }); + const data = useSignal(null, { name: "userData", watched: () => {} }); + return
{count.value}
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1, "MyComponent"); + try { + const count = signal(0, { + name: "myCounter", + }); + const data = useSignal(null, { + name: "userData", + watched: () => {}, + }); + return
{count.value}
; + } finally { + _effect.f(); + } + } + ` + + await runDebugTest(inputCode, expectedOutput, 'Component.js') + }) + + it('handles signals with no initial value', async () => { + const inputCode = ` + function MyComponent() { + const count = useSignal(); + return
{count.value}
; + } + ` + + const expectedOutput = ` + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1, "MyComponent"); + try { + const count = useSignal(undefined, { + name: "count (Component.js:3)", + }); + return
{count.value}
; + } finally { + _effect.f(); + } + } + ` + + await runDebugTest(inputCode, expectedOutput, 'Component.js') + }) + }) + + describe('detectTransformedJSX option', () => { + it('detects elements created using react/jsx-runtime import', async () => { + const inputCode = ` + import { jsx as _jsx } from "react/jsx-runtime"; + function MyComponent() { + signal.value; + return _jsx("div", { children: "Hello World" }); + }; + ` + + const expectedOutput = ` + import { jsx as _jsx } from "react/jsx-runtime"; + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return _jsx("div", { + children: "Hello World", + }); + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput, { + detectTransformedJSX: true, + }) + }) + + it('detects elements created using react/jsx-runtime cjs require', async () => { + const inputCode = ` + const jsxRuntime = require("react/jsx-runtime"); + function MyComponent() { + signal.value; + return jsxRuntime.jsx("div", { children: "Hello World" }); + }; + ` + + const expectedOutput = ` + var _useSignals = require("@preact/signals-react/runtime").useSignals + const jsxRuntime = require("react/jsx-runtime"); + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return jsxRuntime.jsx("div", { + children: "Hello World", + }); + } finally { + _effect.f(); + } + } + ` + + await runTest( + inputCode, + expectedOutput, + { + detectTransformedJSX: true, + }, + undefined, + true, + ) + }) + + it('detects elements created using react/jsx-runtime cjs destuctured import', async () => { + const inputCode = ` + const { jsx } = require("react/jsx-runtime"); + function MyComponent() { + signal.value; + return jsx("div", { children: "Hello World" }); + }; + ` + + const expectedOutput = ` + var _useSignals = require("@preact/signals-react/runtime").useSignals + const { jsx } = require("react/jsx-runtime"); + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return jsx("div", { + children: "Hello World", + }); + } finally { + _effect.f(); + } + } + ` + + await runTest( + inputCode, + expectedOutput, + { + detectTransformedJSX: true, + }, + undefined, + true, + ) + }) + + it('does not detect jsx-runtime calls when detectJSXAlternatives is disabled', async () => { + const inputCode = ` + import { jsx as _jsx } from "react/jsx-runtime"; + function MyComponent() { + signal.value; + return _jsx("div", { children: "Hello World" }); + }; + ` + + // Should not transform because jsx-runtime detection is disabled - no useSignals import should be added + const expectedOutput = ` + import { jsx as _jsx } from "react/jsx-runtime"; + function MyComponent() { + signal.value; + return _jsx("div", { + children: "Hello World", + }); + } + ` + + await runTest(inputCode, expectedOutput, { + detectTransformedJSX: false, + }) + }) + + it('detects createElement calls created using react import', async () => { + const inputCode = ` + import { createElement } from "react"; + function MyComponent() { + signal.value; + return createElement("div", { children: "Hello World" }); + }; + ` + + const expectedOutput = ` + import { createElement } from "react"; + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return createElement("div", { + children: "Hello World", + }); + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput, { + detectTransformedJSX: true, + }) + }) + + it('detects createElement calls created using react default import', async () => { + const inputCode = ` + import React from "react"; + function MyComponent() { + signal.value; + return React.createElement("div", { children: "Hello World" }); + }; + ` + + const expectedOutput = ` + import React from "react"; + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return React.createElement("div", { + children: "Hello World", + }); + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput, { + detectTransformedJSX: true, + }) + }) + + it('detects createElement calls created using react cjs require', async () => { + const inputCode = ` + const React = require("react"); + function MyComponent() { + signal.value; + return React.createElement("div", { children: "Hello World" }); + }; + ` + + const expectedOutput = ` + var _useSignals = require("@preact/signals-react/runtime").useSignals + const React = require("react"); + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return React.createElement("div", { + children: "Hello World", + }); + } finally { + _effect.f(); + } + } + ` + + await runTest( + inputCode, + expectedOutput, + { + detectTransformedJSX: true, + }, + undefined, + true, + ) + }) + + it('detects createElement calls created using destructured react cjs require', async () => { + const inputCode = ` + const { createElement } = require("react"); + function MyComponent() { + signal.value; + return createElement("div", { children: "Hello World" }); + }; + ` + + const expectedOutput = ` + var _useSignals = require("@preact/signals-react/runtime").useSignals + const { createElement } = require("react"); + function MyComponent() { + var _effect = _useSignals(1); + try { + signal.value; + return createElement("div", { + children: "Hello World", + }); + } finally { + _effect.f(); + } + } + ` + + await runTest( + inputCode, + expectedOutput, + { + detectTransformedJSX: true, + }, + undefined, + true, + ) + }) + + it('detects signal access in nested functions', async () => { + const inputCode = ` + import { jsx } from "react/jsx-runtime"; + function MyComponent(props) { + return props.listSignal.value.map(function iteration(x) { + return jsx("div", { children: x }); + }); + }; + ` + + const expectedOutput = ` + import { jsx } from "react/jsx-runtime"; + import { useSignals as _useSignals } from "@preact/signals-react/runtime"; + function MyComponent(props) { + var _effect = _useSignals(1); + try { + return props.listSignal.value.map(function iteration(x) { + return jsx("div", { + children: x, + }); + }); + } finally { + _effect.f(); + } + } + ` + + await runTest(inputCode, expectedOutput, { + detectTransformedJSX: true, + }) + }) + }) +}) diff --git a/packages/react-signals-transform/tsdown.config.ts b/packages/react-signals-transform/tsdown.config.ts new file mode 100644 index 0000000..a3a2720 --- /dev/null +++ b/packages/react-signals-transform/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: './src/index.ts', + dts: { + tsconfig: '../../tsconfig.common.json', + tsgo: true, + }, +}) diff --git a/packages/react-signals-transform/vitest.config.ts b/packages/react-signals-transform/vitest.config.ts new file mode 100644 index 0000000..6bcd90f --- /dev/null +++ b/packages/react-signals-transform/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'react-signals-transform', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 160eddb..4766d01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,7 @@ importers: version: 0.21.2(@typescript/native-preview@7.0.0-dev.20260314.1)(publint@0.3.17) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@24.12.0)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)) + version: 4.1.0(@types/node@24.12.0)(jsdom@26.1.0)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)) examples: devDependencies: @@ -220,6 +220,43 @@ importers: specifier: ^1.0.0-rc.9 version: 1.0.0-rc.9 + packages/react-signals-transform: + dependencies: + rolldown-string: + specifier: ^0.3.0 + version: 0.3.0(rolldown@1.0.0-rc.9) + devDependencies: + '@preact/signals-core': + specifier: ^1.12.1 + version: 1.14.0 + '@preact/signals-react': + specifier: ^3.9.1 + version: 3.9.1(react@18.3.1) + '@types/react': + specifier: ^18.3.23 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.28) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + prettier: + specifier: ^3.6.2 + version: 3.8.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + rolldown: + specifier: ^1.0.0-rc.9 + version: 1.0.0-rc.9 + vite: + specifier: ^8.0.0 + version: 8.0.0(@types/node@24.12.0)(esbuild@0.27.3) + scripts: devDependencies: '@vitejs/release-scripts': @@ -234,6 +271,9 @@ importers: packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -439,6 +479,34 @@ packages: conventional-commits-parser: optional: true + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -1186,6 +1254,14 @@ packages: cpu: [x64] os: [win32] + '@preact/signals-core@1.14.0': + resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==} + + '@preact/signals-react@3.9.1': + resolution: {integrity: sha512-4bj3wUfXrYOqDDs6sX2Y5GBC8jgSa8ah8ZJHN2A+23ej+TdPDrtwVJ0e1cEhwemyOZ7Q7NHbilPRhtd5zVpaBA==} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + '@publint/pack@0.1.4': resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} @@ -1480,11 +1556,22 @@ packages: '@types/picomatch@4.0.2': resolution: {integrity: sha512-qHHxQ+P9PysNEGbALT8f8YOSHW0KJu6l2xU8DYY0fu/EmGxXdVnuTLvFUvBgPJMSqXq29SYHveejeAha+4AYgA==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: '@types/react': ^19.2.0 + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -1577,6 +1664,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -1671,9 +1762,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1683,6 +1782,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -1710,6 +1812,10 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1808,6 +1914,18 @@ packages: resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==} engines: {node: ^18.17.0 || >=20.5.0} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -1816,6 +1934,10 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1842,6 +1964,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1863,6 +1988,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1957,6 +2091,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2020,6 +2158,9 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2070,6 +2211,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2116,6 +2260,11 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -2129,9 +2278,18 @@ packages: engines: {node: '>=18'} hasBin: true + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -2140,6 +2298,10 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -2193,10 +2355,23 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2276,6 +2451,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2295,6 +2473,21 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2374,6 +2567,11 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -2455,13 +2653,34 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2475,6 +2694,25 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2488,6 +2726,14 @@ packages: snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2757,6 +3003,26 @@ snapshots: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.2.1 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -3239,6 +3505,14 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.55.0': optional: true + '@preact/signals-core@1.14.0': {} + + '@preact/signals-react@3.9.1(react@18.3.1)': + dependencies: + '@preact/signals-core': 1.14.0 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + '@publint/pack@0.1.4': {} '@quansync/fs@1.0.0': @@ -3432,10 +3706,21 @@ snapshots: '@types/picomatch@4.0.2': {} + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -3534,6 +3819,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ansis@4.2.0: {} array-ify@1.0.0: {} @@ -3629,12 +3916,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + defu@6.1.4: {} detect-libc@2.1.2: {} @@ -3649,6 +3948,8 @@ snapshots: empathic@2.0.0: {} + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -3779,10 +4080,32 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} human-signals@8.0.1: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3802,6 +4125,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@3.0.0: {} is-stream@4.0.1: {} @@ -3814,6 +4139,33 @@ snapshots: js-tokens@4.0.0: {} + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -3873,6 +4225,10 @@ snapshots: lines-and-columns@1.2.4: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -3935,6 +4291,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + nwsapi@2.2.23: {} + obug@2.1.1: {} onetime@6.0.0: @@ -4042,6 +4400,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-key@3.1.1: {} path-key@4.0.0: {} @@ -4076,6 +4438,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.8.1: {} + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -4092,8 +4456,16 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + punycode@2.3.1: {} + quansync@1.0.0: {} + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -4101,6 +4473,10 @@ snapshots: react-is@16.13.1: {} + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + react@19.2.4: {} regexp-tree@0.1.27: {} @@ -4159,10 +4535,22 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 + rrweb-cssom@0.8.0: {} + sade@1.8.1: dependencies: mri: 1.2.0 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4215,6 +4603,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -4228,6 +4618,20 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} tsdown@0.21.2(@typescript/native-preview@7.0.0-dev.20260314.1)(publint@0.3.17): @@ -4295,6 +4699,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -4326,7 +4734,7 @@ snapshots: esbuild: 0.27.3 fsevents: 2.3.3 - vitest@4.1.0(@types/node@24.12.0)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)): + vitest@4.1.0(@types/node@24.12.0)(jsdom@26.1.0)(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@24.12.0)(esbuild@0.27.3)) @@ -4350,13 +4758,31 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 + jsdom: 26.1.0 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} + webidl-conversions@7.0.0: {} + webpack-virtual-modules@0.6.2: {} + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4368,6 +4794,12 @@ snapshots: wordwrap@1.0.0: {} + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@1.10.2: {}