Skip to content

Commit 3458d69

Browse files
committed
2.7.2, 2.7.3 fix:getters in nested components, FF license key persistence
Closes #405 - when nested data is available, make it available to any getters defined by the component - when a getter is detected on a component, hook into parent component changes to emit component data updates
1 parent 141e066 commit 3458d69

File tree

9 files changed

+240
-59
lines changed

9 files changed

+240
-59
lines changed

packages/browser-extension/AGENTS.md

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,29 @@ The extension has three main parts:
1616

1717
Here is a breakdown of the important files and directories in the `packages/browser-extension` directory:
1818

19-
* **`manifest.json`**: The main configuration file for the extension. It defines the extension's name, version, permissions, and the scripts it uses.
19+
- **`manifest.json`**: The main configuration file for the extension. It defines the extension's name, version, permissions, and the scripts it uses.
2020

21-
* **`src/`**: This directory contains the main source code for the extension.
21+
- **`src/`**: This directory contains the main source code for the extension.
22+
- **`src/devtools/`**: This directory contains the source code for the devtools panel. It is built with Solid.js and TypeScript.
23+
- **`panel.tsx`**: The entry point for the devtools panel.
24+
- **`App.tsx`**: The main application component for the devtools panel.
25+
- **`components/`**: Contains the Solid.js components used in the devtools panel.
26+
- **`state/`**: Contains the state management logic for the devtools panel.
2227

23-
* **`src/devtools/`**: This directory contains the source code for the devtools panel. It is built with Solid.js and TypeScript.
24-
* **`panel.tsx`**: The entry point for the devtools panel.
25-
* **`App.tsx`**: The main application component for the devtools panel.
26-
* **`components/`**: Contains the Solid.js components used in the devtools panel.
27-
* **`state/`**: Contains the state management logic for the devtools panel.
28+
- **`src/lib/`**: This directory contains shared utility functions.
2829

29-
* **`src/lib/`**: This directory contains shared utility functions.
30+
- **`src/scripts/`**: This directory contains the background scripts, content scripts, and other scripts that run in the browser.
31+
- **`background.ts`**: The service worker that runs in the background.
32+
- **`content.ts`**: The content script that is injected into the web page.
33+
- **`detector.ts`**: A script that detects the Alpine.js components on the page.
3034

31-
* **`src/scripts/`**: This directory contains the background scripts, content scripts, and other scripts that run in the browser.
32-
* **`background.ts`**: The service worker that runs in the background.
33-
* **`content.ts`**: The content script that is injected into the web page.
34-
* **`detector.ts`**: A script that detects the Alpine.js components on the page.
35+
- **`assets/`**: This directory contains static assets like images, HTML files, and icons.
3536

36-
* **`assets/`**: This directory contains static assets like images, HTML files, and icons.
37+
- **`cypress/`**: This directory contains the end-to-end tests for the extension.
3738

38-
* **`cypress/`**: This directory contains the end-to-end tests for the extension.
39+
- **`package.json`**: This file lists the project's dependencies and scripts.
3940

40-
* **`package.json`**: This file lists the project's dependencies and scripts.
41-
42-
* **`README.md`**: This file provides a general overview of the project.
41+
- **`README.md`**: This file provides a general overview of the project.
4342

4443
## How the Parts Work Together
4544

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
describe('Nested Getters', () => {
2+
it('should display getters using parent component data correctly', () => {
3+
cy.visit('/simulator?target=nested-getters.html')
4+
.get('[data-testid=component-name]')
5+
.should('be.visible');
6+
7+
// Select the child component
8+
cy.get('[data-testid=component-name]').contains('child').click();
9+
10+
// Check that the "doubledCount" getter is displayed correctly
11+
cy.get('[data-testid=data-property-name-doubledCount]')
12+
.should('be.visible')
13+
.should('have.text', 'doubledCount');
14+
15+
cy.get('[data-testid=data-property-value-doubledCount]')
16+
.should('be.visible')
17+
.should('have.text', '0');
18+
19+
// Check that the "doubledHello" getter is displayed, confirming the fix
20+
cy.get('[data-testid=data-property-name-doubledHello]')
21+
.should('be.visible')
22+
.should('have.text', 'doubledHello');
23+
24+
cy.get('[data-testid=data-property-value-doubledHello]')
25+
.should('be.visible')
26+
.should('have.text', '"one, two, three"');
27+
});
28+
29+
it('should display update getter values when parent component data changes', () => {
30+
cy.visit('/simulator?target=nested-getters.html')
31+
.get('[data-testid=component-name]')
32+
.should('be.visible');
33+
34+
// Select the child component
35+
cy.get('[data-testid=component-name]').contains('child').click();
36+
37+
cy.get('[data-testid=data-property-name-doubledCount]')
38+
.should('be.visible')
39+
.should('have.text', 'doubledCount');
40+
41+
cy.get('[data-testid=data-property-value-doubledCount]')
42+
.should('be.visible')
43+
.should('have.text', '0');
44+
45+
cy.get('[data-testid=data-property-name-doubledHello]')
46+
.should('be.visible')
47+
.should('have.text', 'doubledHello');
48+
49+
cy.get('[data-testid=data-property-value-doubledHello]')
50+
.should('be.visible')
51+
.should('have.text', '"one, two, three"');
52+
53+
// Click
54+
cy.iframe('#target').find('[data-testid=click-target]').click();
55+
56+
cy.get('[data-testid=data-property-value-doubledCount]')
57+
.should('be.visible')
58+
.should('have.text', '2');
59+
60+
cy.get('[data-testid=data-property-value-doubledHello]')
61+
.should('be.visible')
62+
.should('have.text', '"one, two, three, new"');
63+
});
64+
});

packages/browser-extension/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "Alpine.js devtools - Early Access",
33
"description": "DevTools extension for debugging Alpine.js applications.",
4-
"version": "2.7.1",
4+
"version": "2.7.3",
55
"manifest_version": 3,
66
"icons": {
77
"16": "icons/16.png",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Alpine.js Devtools Example</title>
6+
<!-- <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> -->
7+
<!-- Uncomment the following script to use that version, eg. when working offline -->
8+
<script src="/cdn.min.js" defer></script>
9+
</head>
10+
11+
<body>
12+
<div x-data="parent">
13+
<h1 x-text="title"></h1>
14+
<p>Times clicked: <span x-text="count"></span></p>
15+
<p>Messages:</p>
16+
<ul>
17+
<template x-for="message in messages">
18+
<li x-text="message"></li>
19+
</template>
20+
</ul>
21+
<div x-data="child">
22+
<h2 x-text="title"></h2>
23+
<p>Times clicked doubled: <span x-text="doubledCount"></span></p>
24+
<p x-text="doubledHello"></p>
25+
</div>
26+
<p><button @click="onClick" data-testid="click-target">Click me</button></p>
27+
</div>
28+
29+
<script>
30+
document.addEventListener('alpine:initializing', () => {
31+
Alpine.data('parent', () => ({
32+
title: 'Parent component',
33+
count: 0,
34+
messages: ['one', 'two', 'three'],
35+
onClick() {
36+
this.count++;
37+
this.messages.push('new');
38+
},
39+
}));
40+
41+
Alpine.data('child', () => ({
42+
title: 'Child component',
43+
get doubledCount() {
44+
return this.count * 2;
45+
},
46+
get doubledHello() {
47+
return this.messages.join(', ');
48+
},
49+
}));
50+
});
51+
</script>
52+
</body>
53+
</html>

packages/browser-extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "browser-extension",
33
"private": true,
4-
"version": "2.7.1",
4+
"version": "2.7.3",
55
"description": "Alpine.js devtools with Chrome manifest v3 compatibility",
66
"type": "module",
77
"scripts": {

packages/browser-extension/src/devtools/components/debug-menu.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ export function DebugMenu() {
3131

3232
<section>
3333
<div>
34-
<button class="btn" onClick={() => chrome.storage.sync.clear()}>
34+
<button
35+
class="btn"
36+
onClick={() => {
37+
chrome.storage.sync.clear();
38+
chrome.storage.local.clear();
39+
}}
40+
>
3541
Clear storage
3642
</button>
3743
</div>

packages/browser-extension/src/lib/isEarlyAccess.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,23 @@ export const isEarlyAccessExpiryInPast = createMemo(() => {
2323
});
2424

2525
export function loadPersistedEarlyAccessInfo() {
26-
chrome.storage.sync.get('earlyAccess', (result) => {
26+
const handleStorageResult = (result: any) => {
2727
if (result.earlyAccess && result.earlyAccess.expiry) {
2828
setEarlyAccessStore({
2929
expiry: result.earlyAccess.expiry,
3030
isEarlyAccess: result.earlyAccess.expiry > Date.now(),
3131
});
3232
}
33-
});
33+
};
34+
chrome.storage.sync.get('earlyAccess', handleStorageResult);
35+
chrome.storage.local.get('earlyAccess', handleStorageResult);
3436
}
3537

3638
export function startTrial() {
3739
const expiry = Date.now() + daysToMs(7);
38-
chrome.storage.sync.set({ earlyAccess: { expiry } });
40+
const storagePayload = { earlyAccess: { expiry } };
41+
chrome.storage.sync.set(storagePayload);
42+
chrome.storage.local.set(storagePayload);
3943
setEarlyAccessStore({
4044
isEarlyAccess: true,
4145
expiry,
@@ -76,7 +80,9 @@ export async function activateLicense(
7680

7781
if (data.enabled) {
7882
const expiry = Infinity;
79-
chrome.storage.sync.set({ earlyAccess: { expiry, key: licenseKey } });
83+
const storagePayload = { earlyAccess: { expiry, key: licenseKey } };
84+
chrome.storage.sync.set(storagePayload);
85+
chrome.storage.local.set(storagePayload);
8086
setEarlyAccessStore({
8187
isEarlyAccess: true,
8288
expiry,

packages/browser-extension/src/mock-chrome-api.ts

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@ const mockPort: chrome.runtime.Port = {
2626
sender: undefined,
2727
};
2828

29+
const fakeStorage = {
30+
clear() {
31+
window.localStorage.removeItem('chrome-local');
32+
},
33+
set(partial) {
34+
console.log('setting partial', partial);
35+
window.localStorage.setItem('chrome-local', JSON.stringify(partial));
36+
},
37+
getKeys() {
38+
const storage = window.localStorage.getItem('chrome-local');
39+
return storage ? Promise.resolve(Object.keys(JSON.parse(storage))) : Promise.resolve([]);
40+
},
41+
get(key, cb) {
42+
const raw = window.localStorage.getItem('chrome-local');
43+
try {
44+
if (!raw) {
45+
throw new Error('chrome-local not set');
46+
}
47+
const parsed = JSON.parse(raw);
48+
if (cb) {
49+
cb(parsed);
50+
}
51+
return Promise.resolve(parsed[key]);
52+
} catch (e) {
53+
console.log("Key doesn't exist: ", key, raw, e);
54+
}
55+
},
56+
} as chrome.storage.SyncStorageArea;
57+
2958
globalThis.chrome = {
3059
devtools: {
3160
panels: {
@@ -161,33 +190,7 @@ globalThis.chrome = {
161190
setPopup: () => {},
162191
},
163192
storage: {
164-
sync: {
165-
clear() {
166-
window.localStorage.removeItem('chrome-local');
167-
},
168-
set(partial) {
169-
console.log('setting partial', partial);
170-
window.localStorage.setItem('chrome-local', JSON.stringify(partial));
171-
},
172-
getKeys() {
173-
const storage = window.localStorage.getItem('chrome-local');
174-
return storage ? Promise.resolve(Object.keys(JSON.parse(storage))) : Promise.resolve([]);
175-
},
176-
get(key, cb) {
177-
const raw = window.localStorage.getItem('chrome-local');
178-
try {
179-
if (!raw) {
180-
throw new Error('chrome-local not set');
181-
}
182-
const parsed = JSON.parse(raw);
183-
if (cb) {
184-
cb(parsed);
185-
}
186-
return Promise.resolve(parsed[key]);
187-
} catch (e) {
188-
console.log("Key doesn't exist: ", key, raw, e);
189-
}
190-
},
191-
} as chrome.storage.SyncStorageArea,
193+
sync: fakeStorage,
194+
local: fakeStorage,
192195
},
193196
} as unknown as typeof chrome;

packages/browser-extension/src/scripts/backend.js

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,26 +113,63 @@ export function init(forceStart = false) {
113113
}, MSG_DEBOUNCE_MS * 2);
114114
}
115115

116-
getAlpineDataInstance(node) {
116+
/**
117+
* @param {{ _x_dataStack: Array<unknown>} | { __x: unknown}} node
118+
* @param {{ getRawInstance: boolean }} config
119+
* @returns
120+
*/
121+
getAlpineDataInstance(node, config = { getRawInstance: true }) {
117122
if (this.isV3) {
118-
return node._x_dataStack?.[0];
123+
if (config.getRawInstance || node._x_dataStack.length === 1) {
124+
return node._x_dataStack?.[0];
125+
}
126+
// For accessing the data contents, we need to inherit scoped data.
127+
let i = node._x_dataStack.length - 1;
128+
let mergedDataStack = {};
129+
const leafDataObj = {};
130+
while (i >= 0) {
131+
const stackEntry = node._x_dataStack[i];
132+
const isLeafStackEntry = i === 0;
133+
if (!isLeafStackEntry) {
134+
mergedDataStack = Object.assign(mergedDataStack, stackEntry);
135+
} else {
136+
Object.entries(Object.getOwnPropertyDescriptors(stackEntry)).forEach(
137+
([prop, descriptor]) => {
138+
if (!descriptor.enumerable) {
139+
// magics are non-enumerable
140+
return;
141+
}
142+
if (typeof descriptor.get === 'function') {
143+
// this is a getter, evaluate with nested context
144+
leafDataObj[prop] = descriptor.get.call(mergedDataStack);
145+
// TODO: need to hide the edit button etc, if this
146+
// doesn't have a `descriptor.set !== 'function'` function
147+
// and/or `!!descriptor.writable`
148+
} else {
149+
leafDataObj[prop] = descriptor.value;
150+
}
151+
return;
152+
},
153+
);
154+
}
155+
156+
i--;
157+
}
158+
return leafDataObj;
119159
}
120160
return node.__x;
121161
}
122162

123163
getReadOnlyAlpineData(node) {
124-
const alpineDataInstance = this.getAlpineDataInstance(node);
164+
const alpineDataInstance = this.getAlpineDataInstance(node, { getRawInstance: false });
125165
if (!alpineDataInstance) {
126166
if (import.meta.env.DEV) {
127167
console.warn('element has no dataStack', node);
128168
}
129169
return;
130170
}
131171
if (this.isV3) {
132-
// in v3 magics are registered on the data stack
133-
return Object.fromEntries(
134-
Object.entries(alpineDataInstance).filter(([key]) => !key.startsWith('$')),
135-
);
172+
return alpineDataInstance;
136173
} else {
137174
return alpineDataInstance?.getUnobservedData();
138175
}
@@ -293,6 +330,19 @@ export function init(forceStart = false) {
293330
let recursionDepth = 0;
294331
function visit(componentData, key) {
295332
recursionDepth += 1;
333+
const descriptor = Object.getOwnPropertyDescriptor(componentData, key);
334+
if (descriptor.get && !descriptor.set && !descriptor.value) {
335+
// this is a getter, we don't need to re-run getters
336+
// unless there's a setter
337+
for (const stack of rootEl._x_dataStack.slice(1)) {
338+
// But ensure Alpine recomputes this effect if any of
339+
// the parents change as they could be used in the getter
340+
Object.keys(stack).forEach((k) => {
341+
visit(stack, k);
342+
});
343+
}
344+
return;
345+
}
296346
// since effects track which dependencies are accessed,
297347
// run a fake component data access so that the effect runs
298348
void componentData[key];

0 commit comments

Comments
 (0)