diff --git a/packages/browser-vm/__tests__/__snapshots__/dynamic-style.spec.ts.snap b/packages/browser-vm/__tests__/__snapshots__/dynamic-style.spec.ts.snap new file mode 100644 index 000000000..6f57f506a --- /dev/null +++ b/packages/browser-vm/__tests__/__snapshots__/dynamic-style.spec.ts.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Sandbox: dynamic style append text node 1`] = ` +Array [ + ".app .a { color: black; }.app .b { color: white; }", + Array [ + ".app .a {color: black;}", + ".app .b {color: white;}", + ], +] +`; + +exports[`Sandbox: dynamic style insertRule 1`] = ` +Array [ + ".app .a {color: black;}", + ".app .b {color: white;}", + ".app .c {color: white;}", +] +`; + +exports[`Sandbox: dynamic style set textContent 1`] = ` +Array [ + ".app .a { color: black; }", + Array [ + ".app .a {color: black;}", + ], +] +`; diff --git a/packages/browser-vm/__tests__/dom-side-effect.spec.ts b/packages/browser-vm/__tests__/dom-side-effect.spec.ts index 1dd10fbe0..750365e9b 100644 --- a/packages/browser-vm/__tests__/dom-side-effect.spec.ts +++ b/packages/browser-vm/__tests__/dom-side-effect.spec.ts @@ -1,10 +1,6 @@ import { Sandbox } from '../src/sandbox'; import assert from 'assert'; import { sandboxMap } from '../src/utils'; -import { - recordStyledComponentCSSRules, - rebuildCSSRules, -} from '../src/dynamicNode'; // Garfish 使用 Proxy 对 dom 进行了劫持, 同时对调用 dom 的函数做了劫持, 修正 dom 节点的类型 // 对调用 dom 的相关方法进行测试 diff --git a/packages/browser-vm/__tests__/dom.spec.ts b/packages/browser-vm/__tests__/dom.spec.ts index 83af3766e..f750581d8 100644 --- a/packages/browser-vm/__tests__/dom.spec.ts +++ b/packages/browser-vm/__tests__/dom.spec.ts @@ -1,9 +1,5 @@ import { Sandbox } from '../src/sandbox'; import { sandboxMap } from '../src/utils'; -import { - recordStyledComponentCSSRules, - rebuildCSSRules, -} from '../src/dynamicNode'; // Garfish 使用 Proxy 对 dom 进行了劫持, 同时对调用 dom 的函数做了劫持, 修正 dom 节点的类型 // 对调用 dom 的相关方法进行测试 @@ -160,49 +156,4 @@ describe('Sandbox:Dom & Bom', () => { { next }, ); }); - - const createStyleComponentElementWithRecord = () => { - const styleComponentElement = document.createElement('style'); - document.head.appendChild(styleComponentElement); - const cssRuleExample1 = '.test1 { color: red }'; - const cssRuleExample2 = '.test2 { color: red }'; - styleComponentElement.sheet?.insertRule(`${cssRuleExample1}`); - styleComponentElement.sheet?.insertRule(`${cssRuleExample2}`); - sandbox.dynamicStyleSheetElementSet.add(styleComponentElement); - - recordStyledComponentCSSRules( - sandbox.dynamicStyleSheetElementSet, - sandbox.styledComponentCSSRulesMap, - ); - return styleComponentElement; - }; - - it('should record the css rules of styled-components correctly', () => { - const styleComponentElement = createStyleComponentElementWithRecord(); - const cssRules = sandbox.styledComponentCSSRulesMap.get( - styleComponentElement, - ); - expect(cssRules?.length).toEqual(2); - expect((cssRules?.[0] as CSSStyleRule).selectorText).toEqual('.test2'); - expect((cssRules?.[1] as CSSStyleRule).selectorText).toEqual('.test1'); - }); - - it('should rebuild the css rules of styled-components in the correct order', () => { - const styleComponentElement = createStyleComponentElementWithRecord(); - - Object.defineProperty(window.HTMLStyleElement.prototype, 'sheet', { - writable: true, - value: {}, - }); - // @ts-ignore - styleComponentElement.sheet = new CSSStyleSheet(); - rebuildCSSRules( - sandbox.dynamicStyleSheetElementSet, - sandbox.styledComponentCSSRulesMap, - ); - const cssRules = styleComponentElement.sheet.cssRules; - expect(cssRules.length).toEqual(2); - expect((cssRules?.[0] as CSSStyleRule).selectorText).toEqual('.test2'); - expect((cssRules?.[1] as CSSStyleRule).selectorText).toEqual('.test1'); - }); }); diff --git a/packages/browser-vm/__tests__/dynamic-style.spec.ts b/packages/browser-vm/__tests__/dynamic-style.spec.ts new file mode 100644 index 000000000..9a4fab226 --- /dev/null +++ b/packages/browser-vm/__tests__/dynamic-style.spec.ts @@ -0,0 +1,155 @@ +import { __MockHead__ } from '@garfish/utils'; +import { Sandbox } from '../src/sandbox'; +import { StyleManager } from '@garfish/loader'; +import { rebuildCSSRules } from '../src/dynamicNode'; + +describe('Sandbox: dynamic style', () => { + let sandbox: Sandbox; + + const go = (code: string | (() => void)) => { + if (typeof code === 'function') { + code = `(${code})();`; + } + return ` + const sandbox = __debug_sandbox__; + const Sandbox = sandbox.constructor; + const nativeWindow = Sandbox.getNativeWindow(); + ${code} + `; + }; + + // Mock style transformer + const styleManagerProto = StyleManager.prototype; + const originalTransformCode = styleManagerProto.transformCode; + styleManagerProto.transformCode = function (code) { + return originalTransformCode('.app ' + code); + }; + + beforeEach(() => { + document.body.innerHTML = `
123
`; + sandbox = new Sandbox({ + namespace: 'app', + el: () => document.querySelector('#root'), + baseUrl: 'http://test.app', + modules: [ + () => ({ + recover() {}, + override: { go, jest, expect }, + }), + ], + }); + }); + + it('set textContent', (done) => { + sandbox.execScript( + go(() => { + const style = document.createElement('style'); + style.textContent = '.a { color: black; }'; + document.head.appendChild(style); + expect([ + style.textContent, + Array.from(style.sheet!.cssRules).map((x) => x.cssText), + ]).toMatchSnapshot(); + done(); + }), + { done }, + ); + }); + + it('append text node', (done) => { + sandbox.execScript( + go(() => { + const style = document.createElement('style'); + style.textContent = '.a { color: black; }'; + document.head.appendChild(style); + style.appendChild(document.createTextNode('.b { color: white; }')); + expect([ + style.textContent, + Array.from(style.sheet!.cssRules).map((x) => x.cssText), + ]).toMatchSnapshot(); + done(); + }), + { done }, + ); + }); + + it('insertRule', (done) => { + sandbox.execScript( + go(() => { + const style = document.createElement('style'); + document.head.appendChild(style); + style.sheet!.insertRule('.b { color: white; }'); + style.sheet!.insertRule('.a { color: black; }'); + style.sheet!.insertRule('.c { color: white; }', 2); + expect( + Array.from(style.sheet!.cssRules).map((x) => x.cssText), + ).toMatchSnapshot(); + done(); + }), + { done }, + ); + }); + + const createStyleComponentElementWithRecord = () => { + sandbox.execScript( + go(() => { + const styleComponentElement = document.createElement('style'); + document.head.appendChild(styleComponentElement); + const cssRuleExample1 = '.test1 { color: red }'; + const cssRuleExample2 = '.test2 { color: red }'; + styleComponentElement.sheet!.insertRule(cssRuleExample1); + styleComponentElement.sheet!.insertRule(cssRuleExample2); + window._styleElement = styleComponentElement; + }), + ); + + const style = sandbox.global!._styleElement as HTMLStyleElement; + sandbox.dynamicStyleSheetElementSet.add(style); + return style; + }; + + it('should record the css rules of styled-components correctly', () => { + const styleComponentElement = createStyleComponentElementWithRecord(); + const data = sandbox.styledComponentCSSRulesMap.get(styleComponentElement); + expect(data!.length).toEqual(2); + expect(data![0].split(' {')[0]).toEqual('.app .test2'); + expect(data![1].split(' {')[0]).toEqual('.app .test1'); + }); + + it('should rebuild the css rules of styled-components in the correct order', () => { + const styleComponentElement = createStyleComponentElementWithRecord(); + + const parent = styleComponentElement.parentElement; + styleComponentElement.remove(); + parent!.appendChild(styleComponentElement); + + rebuildCSSRules( + sandbox.dynamicStyleSheetElementSet, + sandbox.styledComponentCSSRulesMap, + ); + const cssRules = styleComponentElement.sheet!.cssRules; + expect(cssRules.length).toEqual(2); + expect((cssRules![0] as CSSStyleRule).selectorText).toEqual('.app .test2'); + expect((cssRules![1] as CSSStyleRule).selectorText).toEqual('.app .test1'); + }); + + it('should be able to insertRule on old sheet after remount', () => { + const styleComponentElement = createStyleComponentElementWithRecord(); + const oldSheet = styleComponentElement.sheet; + + const parent = styleComponentElement.parentElement; + styleComponentElement.remove(); + parent!.appendChild(styleComponentElement); + + rebuildCSSRules( + sandbox.dynamicStyleSheetElementSet, + sandbox.styledComponentCSSRulesMap, + ); + const cssRules = styleComponentElement.sheet!.cssRules; + expect(cssRules.length).toEqual(2); + expect((cssRules![0] as CSSStyleRule).selectorText).toEqual('.app .test2'); + expect((cssRules![1] as CSSStyleRule).selectorText).toEqual('.app .test1'); + oldSheet!.insertRule('.test3 {}', 2); + expect((cssRules![2] as CSSStyleRule).selectorText).toEqual('.app .test3'); + }); +}); diff --git a/packages/browser-vm/src/dynamicNode/index.ts b/packages/browser-vm/src/dynamicNode/index.ts index f9885f469..8569635ee 100644 --- a/packages/browser-vm/src/dynamicNode/index.ts +++ b/packages/browser-vm/src/dynamicNode/index.ts @@ -3,7 +3,7 @@ import { __domWrapper__ } from '../symbolTypes'; import { injectHandlerParams } from './processParams'; import { DynamicNodeProcessor, rawElementMethods } from './processor'; import { isInIframe, sandboxMap, isStyledComponentsLike } from '../utils'; -import { SandboxOptions } from '../types'; +import { SandboxOptions, StyledComponentCSSRulesData } from '../types'; const mountElementMethods = [ 'append', @@ -137,31 +137,29 @@ export function makeElInjector(sandboxConfig: SandboxOptions) { injectHandlerParams(); } -export function recordStyledComponentCSSRules( - dynamicStyleSheetElementSet: Set, - styledComponentCSSRulesMap: WeakMap, -) { - dynamicStyleSheetElementSet.forEach((styleElement) => { - if (isStyledComponentsLike(styleElement) && styleElement.sheet) { - styledComponentCSSRulesMap.set(styleElement, styleElement.sheet.cssRules); - } - }); -} - export function rebuildCSSRules( dynamicStyleSheetElementSet: Set, - styledComponentCSSRulesMap: WeakMap, + styledComponentCSSRulesMap: WeakMap< + HTMLStyleElement, + StyledComponentCSSRulesData + >, ) { dynamicStyleSheetElementSet.forEach((styleElement) => { - const cssRules = styledComponentCSSRulesMap.get(styleElement); - if (cssRules && (isStyledComponentsLike(styleElement) || cssRules.length)) { - for (let i = 0; i < cssRules.length; i++) { - const cssRule = cssRules[i]; - // re-insert rules for styled-components element - styleElement.sheet?.insertRule( - cssRule.cssText, - styleElement.sheet?.cssRules.length, - ); + const rules = styledComponentCSSRulesMap.get(styleElement); + + if (rules && (isStyledComponentsLike(styleElement) || rules.length)) { + const realSheet = Reflect.get( + HTMLStyleElement.prototype, + 'sheet', + styleElement, + ); + if (realSheet) { + for (let i = 0; i < rules.length; i++) { + const cssRule = rules[i]; + // re-insert rules for styled-components element + // use realSheet to skip transforming + realSheet.insertRule(cssRule, i); + } } } }); diff --git a/packages/browser-vm/src/dynamicNode/processor.ts b/packages/browser-vm/src/dynamicNode/processor.ts index dd2154d97..e63bcd999 100644 --- a/packages/browser-vm/src/dynamicNode/processor.ts +++ b/packages/browser-vm/src/dynamicNode/processor.ts @@ -15,7 +15,8 @@ import { __REMOVE_NODE__, } from '@garfish/utils'; import { Sandbox } from '../sandbox'; -import { rootElm, isStyledComponentsLike, LockQueue } from '../utils'; +import { rootElm, LockQueue } from '../utils'; +import { StyledComponentCSSRulesData } from '../types'; const isInsertMethod = makeMap(['insertBefore', 'insertAdjacentElement']); @@ -252,26 +253,34 @@ export class DynamicNodeProcessor { }; const mutator = new MutationObserver((mutations) => { - for (const { type, target, addedNodes } of mutations) { + for (const { type, addedNodes } of mutations) { if (type === 'childList') { - const el = target as HTMLStyleElement; - if (isStyledComponentsLike(el) && el.sheet) { - const originAddRule = el.sheet.insertRule; - el.sheet.insertRule = function () { - arguments[0] = modifyStyleCode(arguments[0]); - return originAddRule.apply(this, arguments); - }; - } else { - if (addedNodes[0]?.textContent) { - addedNodes[0].textContent = modifyStyleCode( - addedNodes[0].textContent, - ); - } + if (addedNodes[0]?.textContent) { + addedNodes[0].textContent = modifyStyleCode( + addedNodes[0].textContent, + ); } } } }); mutator.observe(this.el, { childList: true }); + + // Handle `sheet.cssRules` (styled-components) + let fakeSheet: any = null; + Reflect.defineProperty(this.el, 'sheet', { + get: () => { + // styled-components only get the `sheet` once, and keep the first + // instance in their state. But the `sheet` will be actually replaced + // with another instance after remount. + // To make insertRule() after remount possible, we return a fake sheet + // here and passthrough operations to the latest real `sheet`. + if (!fakeSheet) { + fakeSheet = this.createFakeSheet(modifyStyleCode); + } + return fakeSheet; + }, + configurable: true, + }); } private findParentNodeInApp(parentNode: Element, defaultInsert?: string) { @@ -432,7 +441,42 @@ export class DynamicNodeProcessor { return this.nativeRemove.call(parentNode, this.el); } } - return originProcess(); } + + private getRealSheet() { + return Reflect.get(HTMLStyleElement.prototype, 'sheet', this.el); + } + + private createFakeSheet( + styleTransformer: (css: string | null) => string | null, + ) { + const processor = this; + const rulesData: StyledComponentCSSRulesData = []; + this.sandbox.styledComponentCSSRulesMap.set(this.el, rulesData); + + const fakeSheet = { + get cssRules() { + const realSheet = processor.getRealSheet(); + return realSheet?.cssRules ?? []; + }, + insertRule(rule: string, index?: number) { + const realSheet = processor.getRealSheet(); + const transformed = styleTransformer(rule)!; + if (realSheet) { + realSheet.insertRule(transformed, index); + } + rulesData.splice(index || 0, 0, transformed); + return index || 0; + }, + deleteRule(index: number) { + const realSheet = processor.getRealSheet(); + if (realSheet) { + realSheet.deleteRule(index); + } + rulesData.splice(index, 1); + }, + }; + return fakeSheet; + } } diff --git a/packages/browser-vm/src/pluginify.ts b/packages/browser-vm/src/pluginify.ts index 804ee4be3..e2a235702 100644 --- a/packages/browser-vm/src/pluginify.ts +++ b/packages/browser-vm/src/pluginify.ts @@ -2,7 +2,7 @@ import { interfaces } from '@garfish/core'; import { warn, isPlainObject } from '@garfish/utils'; import { Module } from './types'; import { Sandbox } from './sandbox'; -import { recordStyledComponentCSSRules, rebuildCSSRules } from './dynamicNode'; +import { rebuildCSSRules } from './dynamicNode'; declare module '@garfish/core' { export default interface Garfish { @@ -147,15 +147,6 @@ function createOptions(Garfish: interfaces.Garfish) { ); }, - beforeUnmount(appInfo, appInstance) { - if (appInstance.vmSandbox) { - recordStyledComponentCSSRules( - appInstance.vmSandbox.dynamicStyleSheetElementSet, - appInstance.vmSandbox.styledComponentCSSRulesMap, - ); - } - }, - // If the app is uninstalled, the sandbox needs to clear all effects and then reset afterUnmount(appInfo, appInstance, isCacheMode) { // The caching pattern to retain the same context diff --git a/packages/browser-vm/src/sandbox.ts b/packages/browser-vm/src/sandbox.ts index a32806f52..2219b4683 100644 --- a/packages/browser-vm/src/sandbox.ts +++ b/packages/browser-vm/src/sandbox.ts @@ -23,7 +23,12 @@ import { makeElInjector } from './dynamicNode'; import { sandboxLifecycle } from './lifecycle'; import { optimizeMethods, createFakeObject, sandboxMap } from './utils'; import { __garfishGlobal__, GARFISH_OPTIMIZE_NAME } from './symbolTypes'; -import { Module, SandboxOptions, ReplaceGlobalVariables } from './types'; +import { + Module, + SandboxOptions, + ReplaceGlobalVariables, + StyledComponentCSSRulesData, +} from './types'; import { createHas, createGetter, @@ -76,7 +81,7 @@ export class Sandbox { public dynamicStyleSheetElementSet = new Set(); public styledComponentCSSRulesMap = new WeakMap< HTMLStyleElement, - CSSRuleList + StyledComponentCSSRulesData >(); private optimizeCode = ''; // To optimize the with statement diff --git a/packages/browser-vm/src/types.ts b/packages/browser-vm/src/types.ts index e5cd8d201..c8747d6a8 100644 --- a/packages/browser-vm/src/types.ts +++ b/packages/browser-vm/src/types.ts @@ -38,3 +38,5 @@ export interface SandboxOptions { protectVariable?: () => Array; insulationVariable?: () => Array; } + +export type StyledComponentCSSRulesData = Array; diff --git a/packages/browser-vm/src/utils.ts b/packages/browser-vm/src/utils.ts index a8da52519..439aac546 100644 --- a/packages/browser-vm/src/utils.ts +++ b/packages/browser-vm/src/utils.ts @@ -166,8 +166,7 @@ export function isStyledComponentsLike(element: HTMLStyleElement) { // A styled-components liked element has no textContent but keep the rules in its sheet.cssRules. return ( element instanceof HTMLStyleElement && - !element.textContent && - element.sheet?.cssRules.length + !element.textContent ); }