Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/@react-spectrum/s2/style/style-macro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,8 +407,23 @@ export function createTheme<T extends Theme>(theme: T): StyleFunction<ThemePrope
if (process.env.NODE_ENV !== 'production') {
js += `let targetRules = rules + ${JSON.stringify(loc)};\n`;
js += 'let hash = 5381;for (let i = 0; i < targetRules.length; i++) { hash = ((hash << 5) + hash) + targetRules.charCodeAt(i) >>> 0; }\n';
js += 'rules += " -macro-dynamic-" + hash.toString(36);\n';
js += `typeof window !== 'undefined' && window?.postMessage?.({action: 'stylemacro-update-macros', hash: hash.toString(36), loc: ${JSON.stringify(loc)}, style: currentRules}, "*");\n`;
js += 'let hashStr = hash.toString(36);\n';
js += 'rules += " -macro-dynamic-" + hashStr;\n';
// Skip global __styleMacroDynamic__ in Jest so we dont' pollute the test environment and don't cause issues with timer advancement.
if (!process.env.JEST_WORKER_ID) {
js += 'if (typeof window !== "undefined") {\n';
js += ' let g = window.__styleMacroDynamic__;\n';
js += ' if (!g) {\n';
js += ' g = window.__styleMacroDynamic__ = { map: {}, _timer: null };\n';
js += ' g._timer = setInterval(function() {\n';
js += ' for (let k in g.map) {\n';
js += ' try { if (!document.querySelector("." + CSS.escape(k))) delete g.map[k]; } catch (e) {}\n';
js += ' }\n';
js += ' }, 300000);\n';
js += ' }\n';
js += ` g.map["-macro-dynamic-" + hashStr] = { style: currentRules, loc: ${JSON.stringify(loc)} };\n`;
js += '}\n';
}
}
js += 'return rules;';
if (allowedOverrides) {
Expand Down
210 changes: 39 additions & 171 deletions packages/dev/style-macro-chrome-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,26 +61,21 @@ This extension uses Chrome's standard extension architecture with three main com
- **Location**: Runs in the actual page's JavaScript context
- **Responsibility**:
- Generates macro metadata (hash, location, styles) when style macro is evaluated
- Hosts MutationObserver that watches selected element for className changes
- **Storage**: None - static macros embed data in CSS, dynamic macros send messages
- **Communication**:
- For static macros: Embeds data in CSS custom property `--macro-data-{hash}` (unique per macro)
- For dynamic macros: Sends `window.postMessage({ action: 'stylemacro-update-macros', hash, loc, style })` to content script
- For className changes: Sends `window.postMessage({ action: 'stylemacro-class-changed', elementId })` to content script
- Hosts MutationObserver (created by dev tool panel) that watches selected element for className changes
- **Storage**:
- Static macros: Data embedded in CSS rules with custom property `--macro-data-{hash}`
- Dynamic macros: Data stored in global `window.__styleMacroDynamic__` (object with `map` of class name β†’ `{ style, loc }` and an internal interval timer for cleanup)

#### 2. **Content Script** (`content-script.js`)
- **Location**: Isolated sandboxed environment injected into the page
- **Scope**: Acts as a message forwarder between page and extension
- **Responsibility**:
- Listens for `window.postMessage({ action: 'stylemacro-update-macros' })` from the page and forwards to background script
- Forwards `window.postMessage({ action: 'stylemacro-class-changed' })` from page to background script
- **Storage**: None - all macro data is stored in DevTools
- Forwards `window.postMessage({ action: 'stylemacro-class-changed' })` from Mutation Observer on page to background script
- **Storage**: None - all macro data is stored in the DOM via CSS custom properties
- **Communication**:
- Receives:
- `window.postMessage({ action: 'stylemacro-update-macros', hash, loc, style })` from page
- `window.postMessage({ action: 'stylemacro-class-changed', elementId })` from page
- Sends:
- `chrome.runtime.sendMessage({ action: 'stylemacro-update-macros', hash, loc, style })` to background
- `chrome.runtime.sendMessage({ action: 'stylemacro-class-changed', elementId })` to background

#### 3. **Background Script** (`background.js`)
Expand All @@ -91,23 +86,19 @@ This extension uses Chrome's standard extension architecture with three main com
- Receives:
- `chrome.runtime.onConnect({ name: 'devtools-page' })` from DevTools
- `port.onMessage({ type: 'stylemacro-init' })` from DevTools
- `chrome.runtime.onMessage({ action: 'stylemacro-update-macros', hash, loc, style })` from content script
- `chrome.runtime.onMessage({ action: 'stylemacro-class-changed', elementId })` from content script
- Sends:
- `port.postMessage({ action: 'stylemacro-update-macros', hash, loc, style })` to DevTools
- `port.postMessage({ action: 'stylemacro-class-changed', elementId })` to DevTools

#### 4. **DevTools Panel** (`devtool.js`)
- **Location**: DevTools sidebar panel context
- **Responsibility**:
- Stores all dynamic macro data in a local Map: `macroData[hash] = { loc, style }`
- Extracts macro class names from selected element:
- Static macros: `-macro-static-{hash}` β†’ reads `--macro-data-{hash}` custom property via `getComputedStyle()`
- Dynamic macros: `-macro-dynamic-{hash}` β†’ looks up data from local storage
- Static macros: `-macro-static-{hash}` β†’ reads `--macro-data-{hash}` custom property via `getComputedStyle()` on the element
- Dynamic macros: `-macro-dynamic-{hash}` β†’ reads from `window.__styleMacroDynamic__.map["-macro-dynamic-{hash}"]` (plain JSON)
- Displays style information in sidebar
- **Automatic Updates**: Sets up a MutationObserver on the selected element to detect className changes and automatically refreshes the panel
- **Cleanup**: Every 5 minutes, checks the DOM for each stored hash and removes data for macros that no longer exist
- **Storage**: `Map<hash, {loc: string, style: object}>` - 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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -310,64 +195,47 @@ 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
- **Background tracks**: Map of `tabId β†’ DevTools port` for routing messages

#### 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

Expand Down
2 changes: 1 addition & 1 deletion packages/dev/style-macro-chrome-plugin/src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading