diff --git a/packages/@react-spectrum/s2/style/style-macro.ts b/packages/@react-spectrum/s2/style/style-macro.ts index c8e604f0fe5..ed8373bcd2c 100644 --- a/packages/@react-spectrum/s2/style/style-macro.ts +++ b/packages/@react-spectrum/s2/style/style-macro.ts @@ -407,8 +407,23 @@ export function createTheme(theme: T): StyleFunction` - stores all dynamic macro data +- **Storage**: None - all data is read from CSS custom properties on demand - **Mutation Observer**: - Created when an element is selected via `chrome.devtools.panels.elements.onSelectionChanged` - Watches the selected element's `class` attribute for changes @@ -117,7 +108,6 @@ This extension uses Chrome's standard extension architecture with three main com - Triggers automatic panel refresh when className changes - **Communication**: - Receives: - - `port.onMessage({ action: 'stylemacro-update-macros', hash, loc, style })` from background (stores data and refreshes) - `port.onMessage({ action: 'stylemacro-class-changed', elementId })` from background (triggers refresh) - Sends: - `chrome.runtime.connect({ name: 'devtools-page' })` to establish connection @@ -149,156 +139,51 @@ Static macros are generated when style macro conditions don't change at runtime. **Key Design**: Each static macro has its own uniquely-named custom property (`--macro-data-{hash}`), which avoids CSS cascade issues when reading multiple macro data from the same element. -#### Flow 1b: Dynamic Macro Updates (Page → DevTools) +#### Flow 1b: Dynamic Macro Updates (Page → Global Variable) -Dynamic macros are generated when style macro conditions can change at runtime. Updates are sent via message passing and stored directly in DevTools. +Dynamic macros are generated when style macro conditions can change at runtime. Data is written to a global JavaScript variable; a timer on the same object cleans up entries whose class is no longer present in the DOM. ``` ┌─────────────────┐ │ Page Context │ -│ (style-macro) │ -└────────┬────────┘ - │ window.postMessage({ action: 'stylemacro-update-macros', hash, loc, style }) - ↓ -┌─────────────────┐ -│ Content Script │ Forwards message (no storage) -└────────┬────────┘ - │ chrome.runtime.sendMessage({ action: 'stylemacro-update-macros', hash, loc, style }) - ↓ -┌─────────────────┐ -│ Background │ Looks up DevTools connection for tabId +│ (style-macro) │ Runtime evaluation with dynamic conditions └────────┬────────┘ - │ port.postMessage({ action: 'stylemacro-update-macros', hash, loc, style }) + │ 1. Ensure window.__styleMacroDynamic__ exists ({ map: {}, _timer }) + │ 2. Set map["-macro-dynamic-{hash}"] = { style, loc } (plain JSON) + │ 3. Timer (e.g. every 5 min) removes entries with no matching element in DOM ↓ ┌─────────────────┐ -│ DevTools Panel │ Stores in macroData Map and triggers sidebar refresh +│ Page (global) │ window.__styleMacroDynamic__.map["-macro-dynamic-{hash}"] = { style, loc } └─────────────────┘ ``` -#### Flow 2: Display Macro Data (Synchronous Lookup) +#### Flow 2: Display Macro Data (Synchronous CSS Lookup) -When the user selects an element or the panel refreshes, DevTools looks up macro data synchronously from its local storage. +When the user selects an element or the panel refreshes, DevTools reads macro data as follows. ``` ┌─────────────────┐ -│ DevTools Panel │ User selects element with -macro-dynamic-{hash} class +│ DevTools Panel │ User selects element with -macro-static-{hash} or -macro-dynamic-{hash} class └────────┬────────┘ │ Extract hash from className ↓ ┌─────────────────┐ -│ DevTools Panel │ Look up macroData.get(hash) -│ Local Storage │ Returns { loc, style } if available +│ DevTools Panel │ Static: getComputedStyle($0).getPropertyValue('--macro-data-{hash}') → JSON.parse +│ │ Dynamic: window.__styleMacroDynamic__.map["-macro-dynamic-{hash}"] → already { style, loc } └────────┬────────┘ - │ { loc: "...", style: {...} } or null + │ ↓ ┌─────────────────┐ -│ DevTools Panel │ Display in sidebar (or show nothing if null) +│ DevTools Panel │ Parses and displays in sidebar └─────────────────┘ ``` -**Note**: If macro data hasn't been received yet for a hash, it will appear empty until the next `stylemacro-update-macros` message arrives and triggers a refresh. +**Note**: Static macros are read from CSS custom properties on the inspected element. Dynamic macros are read from the page’s global `window.__styleMacroDynamic__.map` (no CSS involved). -#### Flow 3: Macro Data Cleanup (Automated) - -Every 5 minutes, DevTools checks if stored macro hashes are still in use on the page and removes stale data. - -``` -┌─────────────────┐ -│ DevTools Panel │ Every 5 minutes -└────────┬────────┘ - │ For each hash in macroData Map: - │ chrome.devtools.inspectedWindow.eval( - │ `!!document.querySelector('.-macro-dynamic-${hash}')` - │ ) - ↓ -┌─────────────────┐ -│ Page DOM │ Checks if elements with macro classes exist -└────────┬────────┘ - │ Returns true/false for each hash - ↓ -┌─────────────────┐ -│ DevTools Panel │ Removes stale entries from macroData Map -│ │ macroData.delete(hash) for non-existent elements -└─────────────────┘ -``` - -#### Flow 4: Automatic Updates on className Changes (MutationObserver) +#### Flow 3: Automatic Updates on className Changes (MutationObserver) When you select an element, the DevTools panel automatically watches for className changes and refreshes the panel. -``` -┌─────────────────┐ -│ DevTools Panel │ User selects element in Elements panel -└────────┬────────┘ - │ chrome.devtools.panels.elements.onSelectionChanged - │ - │ chrome.devtools.inspectedWindow.eval(` - │ // Disconnect old observer (if any) - │ if (window.__styleMacroObserver) { - │ window.__styleMacroObserver.disconnect(); - │ } - │ - │ // Create new MutationObserver on $0 - │ window.__styleMacroObserver = new MutationObserver(() => { - │ window.postMessage({ - │ action: 'stylemacro-class-changed', - │ elementId: $0.__devtoolsId - │ }, '*'); - │ }); - │ - │ window.__styleMacroObserver.observe($0, { - │ attributes: true, - │ attributeFilter: ['class'] - │ }); - │ `) - ↓ -┌─────────────────┐ -│ Page DOM │ MutationObserver active on selected element -└────────┬────────┘ - │ - │ ... User interacts with page, element's className changes ... - │ - │ MutationObserver detects class attribute change - │ window.postMessage({ action: 'stylemacro-class-changed', elementId }, '*') - ↓ -┌─────────────────┐ -│ Content Script │ Receives window message, forwards to extension -└────────┬────────┘ - │ chrome.runtime.sendMessage({ action: 'stylemacro-class-changed', elementId }) - ↓ -┌─────────────────┐ -│ Background │ Looks up DevTools connection for tabId -└────────┬────────┘ - │ port.postMessage({ action: 'stylemacro-class-changed', elementId }) - ↓ -┌─────────────────┐ -│ DevTools Panel │ Verifies elementId matches currently selected element -│ │ Triggers full panel refresh (re-reads classes, re-queries macros) -└─────────────────┘ - -When selection changes or panel closes: - ↓ -┌─────────────────┐ -│ DevTools Panel │ Calls disconnectObserver() -└────────┬────────┘ - │ chrome.devtools.inspectedWindow.eval(` - │ if (window.__styleMacroObserver) { - │ window.__styleMacroObserver.disconnect(); - │ window.__styleMacroObserver = null; - │ } - │ `) - ↓ -┌─────────────────┐ -│ Page DOM │ Old observer disconnected, new observer created for new selection -└─────────────────┘ -``` - -**Key Benefits:** -- Panel automatically refreshes when element classes change (e.g., hover states, conditional styles) -- No manual refresh needed -- Observer is cleaned up properly to prevent memory leaks -- Each element has its own unique tracking ID to prevent cross-contamination - ### Key Technical Details #### Why Background Script is Needed @@ -310,25 +195,16 @@ The style macro generates different class name patterns based on whether the sty **Static Macros** (`-macro-static-{hash}`): - Used when all style conditions are static (e.g., `style({ color: 'red' })`) -- Macro data is embedded in CSS as a uniquely-named custom property: `--macro-data-{hash}: '{...JSON...}'` +- Macro data is embedded in CSS rules as a uniquely-named custom property: `--macro-data-{hash}: '{...JSON...}'` - DevTools reads the specific custom property via `getComputedStyle($0).getPropertyValue('--macro-data-{hash}')` -- Unique naming avoids CSS cascade issues when multiple macros are applied to the same element **Dynamic Macros** (`-macro-dynamic-{hash}`): - Used when style conditions can change (e.g., `style({color: {default: 'blue', isActive: 'red'}})`) -- Macro data is sent via `window.postMessage({ action: 'stylemacro-update-macros', ... })` whenever conditions change -- Content script forwards data to DevTools, which stores it in a local Map +- Macro data is stored in `window.__styleMacroDynamic__.map["-macro-dynamic-{hash}"]` as plain JSON `{ style, loc }` +- A timer on `window.__styleMacroDynamic__` periodically removes map entries whose class is no longer used in the DOM +- DevTools reads via `window.__styleMacroDynamic__.map["-macro-dynamic-{hash}"]` - Enables real-time updates when props/state change -#### Data Storage -- **Static Macros**: Data embedded in CSS as uniquely-named custom properties `--macro-data-{hash}`, read via `getComputedStyle($0).getPropertyValue('--macro-data-{hash}')` - - Each macro has its own custom property name to prevent cascade conflicts - - Example: `.-macro-static-abc123 { --macro-data-abc123: '{"style": {...}, "loc": "..."}'; }` -- **Dynamic Macros**: Data stored in DevTools panel's `macroData` Map -- **No Content Script Storage**: Content script only forwards messages, doesn't store macro data -- **Lifetime**: Macro data persists in DevTools for the duration of the DevTools session -- **Cleanup**: Stale macro data (for elements no longer in DOM) is removed every 5 minutes - #### Connection Management - **DevTools → Background**: Uses persistent `chrome.runtime.connect()` with port-based messaging - **Content Script → Background**: Uses one-time `chrome.runtime.sendMessage()` calls @@ -336,38 +212,30 @@ The style macro generates different class name patterns based on whether the sty #### Data Structure -**Static Macros (in CSS):** +**Static Macros (in main CSS):** ```css .-macro-static-zsZ9Dc { --macro-data-zsZ9Dc: '{"style":{"paddingX":"4"},"loc":"packages/@react-spectrum/s2/src/Button.tsx:67"}'; } ``` -**Dynamic Macros (in DevTools panel's macroData Map):** +**Dynamic Macros (in page global):** ```javascript -Map { - "zsZ9Dc" => { - loc: "packages/@react-spectrum/s2/src/Button.tsx:67", - style: { - "paddingX": "4", - // ... more CSS properties - } - } -} +window.__styleMacroDynamic__ = { + map: { + "-macro-dynamic-zsZ9Dc": { style: { paddingX: "4" }, loc: "packages/@react-spectrum/s2/src/Button.tsx:67" }, + "-macro-dynamic-abc123": { style: {...}, loc: "..." } + }, + _timer: 123 // setInterval for cleanup of unused entries +}; ``` -**Note**: -- Static macro data is stored in CSS with uniquely-named custom properties -- Dynamic macro data is stored directly in the DevTools panel context -- The content script acts purely as a message forwarder and doesn't store any data - #### Message Types | Message Type | Direction | Purpose | |-------------|-----------|---------| -| `stylemacro-update-macros` | Page → Content → Background → DevTools | Send macro data (hash, loc, style) to be stored in DevTools | | `stylemacro-init` | DevTools → Background | Establish connection with tabId | -| `stylemacro-class-changed` | Page → Content → Background → DevTools | Notify that selected element's className changed | +| `stylemacro-class-changed` | Page → Content → Background → DevTools | Notify that selected element's className changed, triggering panel refresh | ### Debugging diff --git a/packages/dev/style-macro-chrome-plugin/src/background.js b/packages/dev/style-macro-chrome-plugin/src/background.js index 6524078c5a0..62a6f7467a6 100644 --- a/packages/dev/style-macro-chrome-plugin/src/background.js +++ b/packages/dev/style-macro-chrome-plugin/src/background.js @@ -36,7 +36,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } // Forward messages from content script to DevTools - if (message.action === 'stylemacro-update-macros' || message.action === 'stylemacro-class-changed') { + if (message.action === 'stylemacro-class-changed') { console.log(`[Background] Forwarding ${message.action} from content script to DevTools, tabId: ${tabId}`); const devtoolsPort = devtoolsConnections.get(tabId); if (devtoolsPort) { diff --git a/packages/dev/style-macro-chrome-plugin/src/content-script.js b/packages/dev/style-macro-chrome-plugin/src/content-script.js index be2418379bf..1bd76336986 100644 --- a/packages/dev/style-macro-chrome-plugin/src/content-script.js +++ b/packages/dev/style-macro-chrome-plugin/src/content-script.js @@ -19,25 +19,7 @@ window.addEventListener('message', function (event) { // Only accept messages that we know are ours. Note that this is not foolproof // and the page can easily spoof messages if it wants to. if (message && typeof message === 'object') { - if (message.action === 'stylemacro-update-macros') { - debugLog('Forwarding stylemacro-update-macros for hash:', message.hash); - - // if this script is run multiple times on the page, then only handle it once - event.stopImmediatePropagation(); - event.stopPropagation(); - - // Forward message directly to background script (which forwards to DevTools) - try { - chrome.runtime.sendMessage({ - action: 'stylemacro-update-macros', - hash: message.hash, - loc: message.loc, - style: message.style - }); - } catch (err) { - debugLog('Failed to send stylemacro-update-macros message:', err); - } - } else if (message.action === 'stylemacro-class-changed') { + if (message.action === 'stylemacro-class-changed') { // Forward class-changed messages from page context to background script debugLog('Forwarding stylemacro-class-changed for element:', message.elementId); diff --git a/packages/dev/style-macro-chrome-plugin/src/devtool.js b/packages/dev/style-macro-chrome-plugin/src/devtool.js index 7587534a07f..42d3acff041 100644 --- a/packages/dev/style-macro-chrome-plugin/src/devtool.js +++ b/packages/dev/style-macro-chrome-plugin/src/devtool.js @@ -28,9 +28,6 @@ chrome.devtools.panels.elements.createSidebarPane('Style Macros', (sidebar) => { }); debugLog('Init message sent to background'); - // Store macro data directly in DevTools - const macroData = new Map(); - // Track mutation observer for selected element let currentObserver = null; let currentElementId = null; @@ -39,17 +36,7 @@ chrome.devtools.panels.elements.createSidebarPane('Style Macros', (sidebar) => { backgroundPageConnection.onMessage.addListener((message) => { debugLog('Message from background:', message); - if (message.action === 'stylemacro-update-macros') { - debugLog('Received stylemacro-update-macros for hash:', message.hash); - // Store the macro data directly in DevTools - macroData.set(message.hash, { - loc: message.loc, - style: message.style - }); - debugLog('Stored macro data, total macros:', macroData.size); - // Refresh the panel to show updated data - update(); - } else if (message.action === 'stylemacro-class-changed') { + if (message.action === 'stylemacro-class-changed') { debugLog('Received stylemacro-class-changed notification for element:', message.elementId); // Only update if the changed element is the one we're currently watching if (message.elementId === currentElementId) { @@ -59,23 +46,62 @@ chrome.devtools.panels.elements.createSidebarPane('Style Macros', (sidebar) => { } }); - // Get macro data from local storage - const getDynamicMacroData = (hash) => { - debugLog('Looking up dynamic macro with hash:', hash); - const data = macroData.get(hash); - debugLog('Found data:', !!data); - return data || null; - }; + // Get all static macro data in one eval (from CSS custom properties on $0). + // Uses the element's window so iframes work correctly. Value is plain JSON. + function getMacroDataStaticBatch(hashes) { + if (hashes.length === 0) { + return Promise.resolve([]); + } + return new Promise((resolve) => { + const hashesJson = JSON.stringify(hashes); + const staticEval = ` +(function () { + var el = $0; + if (!el) return []; + var w = (el.ownerDocument && el.ownerDocument.defaultView) || window; + var s = w.getComputedStyle(el); + var hashes = ${hashesJson}; + return hashes.map(function (h) { + var raw = s.getPropertyValue("--macro-data-" + h).trim(); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch (e) { + return null; + } + }); +})(); + `.trim(); + chrome.devtools.inspectedWindow.eval(staticEval, (results) => { + resolve(Array.isArray(results) ? results : []); + }); + }); + } - function getMacroData(className) { - let promise = new Promise((resolve) => { - debugLog('Getting macro data for:', className); - chrome.devtools.inspectedWindow.eval(`window.getComputedStyle($0).getPropertyValue("--macro-data-${className}")`, (style) => { - debugLog('Got style:', style); - resolve(style ? JSON.parse(style) : null); + // Get all dynamic macro data in one eval. Uses $0's window (correct for iframes). + // Reads from (element's document defaultView).__styleMacroDynamic__.map. + function getMacroDataDynamicBatch(hashes) { + if (hashes.length === 0) { + return Promise.resolve([]); + } + return new Promise((resolve) => { + const hashesJson = JSON.stringify(hashes); + const dynamicEval = ` +(function () { + var el = $0; + var w = (el && el.ownerDocument && el.ownerDocument.defaultView) || window; + var g = w && w.__styleMacroDynamic__; + if (!g || !g.map) return []; + var hashes = ${hashesJson}; + return hashes.map(function (h) { + return g.map["-macro-dynamic-" + h] || null; + }); +})(); + `.trim(); + chrome.devtools.inspectedWindow.eval(dynamicEval, (results) => { + resolve(Array.isArray(results) ? results : []); }); }); - return promise; } // Function to disconnect the current observer @@ -167,16 +193,12 @@ chrome.devtools.panels.elements.createSidebarPane('Style Macros', (sidebar) => { debugLog('Static macro hashes:', staticMacroHashes); debugLog('Dynamic macro hashes:', dynamicMacroHashes); - // Get static macro data (async from CSS) - let staticMacros = staticMacroHashes.map(macro => getMacroData(macro)); - - debugLog('Waiting for', staticMacros.length, 'static macros...'); - let staticResults = await Promise.all(staticMacros); - - // Get dynamic macro data (sync from local storage) - let dynamicResults = dynamicMacroHashes.map(hash => getDynamicMacroData(hash)); - - // Combine results + // Get macro data: static from CSS custom properties on $0, dynamic from element's window.__styleMacroDynamic__.map + debugLog('Waiting for', staticMacroHashes.length, 'static +', dynamicMacroHashes.length, 'dynamic macros...'); + let [staticResults, dynamicResults] = await Promise.all([ + getMacroDataStaticBatch(staticMacroHashes), + getMacroDataDynamicBatch(dynamicMacroHashes) + ]); let results = [...staticResults, ...dynamicResults]; debugLog('Results:', results); @@ -220,49 +242,4 @@ chrome.devtools.panels.elements.createSidebarPane('Style Macros', (sidebar) => { // Initial observation when the panel is first opened startObserving(); - - // Cleanup stale macro data every 5 minutes - const CLEANUP_INTERVAL = 1000 * 60 * 5; - setInterval(() => { - if (macroData.size === 0) { - return; - } - - debugLog('Running macro data cleanup, checking', macroData.size, 'macros...'); - const hashes = Array.from(macroData.keys()); - - // Check all hashes in a single eval for efficiency - const checkScript = ` - (function() { - const hashes = ${JSON.stringify(hashes)}; - const results = {}; - for (const hash of hashes) { - results[hash] = !!document.querySelector('.-macro-dynamic-' + hash); - } - return results; - })(); - `; - - chrome.devtools.inspectedWindow.eval(checkScript, (results, isException) => { - if (isException) { - debugLog('Error during cleanup:', results); - return; - } - - let removedCount = 0; - for (const hash in results) { - if (!results[hash]) { - debugLog('Removing stale macro:', hash); - macroData.delete(hash); - removedCount++; - } - } - - if (removedCount > 0) { - debugLog(`Cleaned up ${removedCount} stale macro(s). Remaining: ${macroData.size}`); - } else { - debugLog('No stale macros found.'); - } - }); - }, CLEANUP_INTERVAL); });