Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: deep nested preact signals in props #11863

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/selfish-spoons-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/preact': patch
---

Make it possible to nest signals as deep as you want in objects and arrays.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ import SignalsInObject from '../components/SignalsInObject';
import ComponentWithNullProp from '../components/ComponentWithNullProp';
const count = signal(1);
const secondCount = signal(2);
const deepNestedObject = {
title:'I am a title',
counter: count,
deep: {
signal: count,
evenDeeper: {
signal: signal(0),
},
},
};
const deepNestedArray = ["I'm not a signal", count, count, 12345, secondCount,
signal(401),
deepNestedObject,
[count, "string"]
];
---
<html>
<head>
Expand All @@ -14,8 +29,8 @@ const secondCount = signal(2);
<body>
<Signals client:load count={count} />
<Signals client:load count={count} />
<SignalsInArray client:load signalsArray={["I'm not a signal", count, count, 12345, secondCount]} />
<SignalsInObject client:load signalsObject={{title:'I am a title', counter: count}}, />
<SignalsInArray client:load signalsArray={deepNestedArray} />
<SignalsInObject client:load signalsObject={deepNestedObject} />
<ComponentWithNullProp client:load nullProp={null} />
</body>
</html>
17 changes: 11 additions & 6 deletions packages/astro/test/preact-component.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,16 @@ describe('Preact component', () => {
const sigs1 = JSON.parse(sigs1Raw);

assert.deepEqual(sigs1, {
signalsArray: [
['p0', 1],
['p0', 2],
['p1', 4],
],
signalsArray: {
1: 'p0',
2: 'p0',
4: 'p1',
5: 'p2',
'6.counter': 'p0',
'6.deep.signal': 'p0',
'6.deep.evenDeeper.signal': 'p3',
'7.0': 'p0',
},
});

assert.equal(element.find('h1').text(), "I'm not a signal 12345");
Expand All @@ -134,7 +139,7 @@ describe('Preact component', () => {
const sigs1 = JSON.parse(sigs1Raw);

assert.deepEqual(sigs1, {
signalsObject: [['p0', 'counter']],
signalsObject: { counter: 'p0', 'deep.signal': 'p0', 'deep.evenDeeper.signal': 'p3' },
});

assert.equal(element.find('h1').text(), 'I am a title');
Expand Down
60 changes: 33 additions & 27 deletions packages/integrations/preact/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { h, hydrate, render } from 'preact';
import StaticHtml from './static-html.js';
import type { SignalLike } from './types.js';
import type { SignalLike, Signals } from './types.js';

const sharedSignalMap = new Map<string, SignalLike>();

export default (element: HTMLElement) =>
async (
Component: any,
Expand All @@ -16,36 +15,43 @@ export default (element: HTMLElement) =>
props[key] = h(StaticHtml, { value, name: key });
}
let signalsRaw = element.dataset.preactSignals;

if (signalsRaw) {
const { signal } = await import('@preact/signals');
let signals: Record<string, string | [string, number][]> = JSON.parse(
element.dataset.preactSignals!,
);
for (const [propName, signalId] of Object.entries(signals)) {
if (Array.isArray(signalId)) {
signalId.forEach(([id, indexOrKeyInProps]) => {
const mapValue = props[propName][indexOrKeyInProps];
let valueOfSignal = mapValue;

// not an property key
if (typeof indexOrKeyInProps !== 'string') {
valueOfSignal = mapValue[0];
indexOrKeyInProps = mapValue[1];
}

if (!sharedSignalMap.has(id)) {
const signalValue = signal(valueOfSignal);
sharedSignalMap.set(id, signalValue);
}
props[propName][indexOrKeyInProps] = sharedSignalMap.get(id);
});
} else {
let signals: Signals = JSON.parse(element.dataset.preactSignals!);

function processProp(value: any, path: string[]): any {
const [topLevelKey, ...restPath] = path;
const nestedPath = restPath.join('.');

if (Array.isArray(value)) {
return value.map((v, index) =>
processProp(v, [topLevelKey, ...restPath, index.toString()]),
);
} else if (typeof value === 'object' && value !== null) {
const newObj: Record<string, any> = {};
for (const [key, val] of Object.entries(value)) {
newObj[key] = processProp(val, [topLevelKey, ...restPath, key]);
}
return newObj;
} else if (signals[topLevelKey]) {
let signalId = signals[topLevelKey];

if (typeof signalId !== 'string') {
signalId = signalId[nestedPath];
}

if (!sharedSignalMap.has(signalId)) {
const signalValue = signal(props[propName]);
sharedSignalMap.set(signalId, signalValue);
sharedSignalMap.set(signalId, signal(value));
}
props[propName] = sharedSignalMap.get(signalId);
return sharedSignalMap.get(signalId);
}

return value;
}

for (const [key, value] of Object.entries(props)) {
props[key] = processProp(value, [key]);
}
}

Expand Down
81 changes: 43 additions & 38 deletions packages/integrations/preact/src/signals.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import type { Context } from './context.js';
import { incrementId } from './context.js';
import type {
ArrayObjectMapping,
AstroPreactAttrs,
PropNameToSignalMap,
SignalLike,
SignalToKeyOrIndexMap,
Signals,
} from './types.js';
import type { AstroPreactAttrs, PropNameToSignalMap, SignalLike, Signals } from './types.js';

function isSignal(x: any): x is SignalLike {
return x != null && typeof x === 'object' && typeof x.peek === 'function' && 'value' in x;
Expand All @@ -34,45 +27,57 @@ export function serializeSignals(
attrs: AstroPreactAttrs,
map: PropNameToSignalMap,
) {
// Check for signals
const signals: Signals = {};
for (const [key, value] of Object.entries(props)) {
const isPropArray = Array.isArray(value);
// `typeof null` is 'object' in JS, so we need to check for `null` specifically
const isPropObject =
!isSignal(value) && typeof props[key] === 'object' && props[key] !== null && !isPropArray;

if (isPropObject || isPropArray) {
const values = isPropObject ? Object.keys(props[key]) : value;
values.forEach((valueKey: number | string, valueIndex: number) => {
const signal = isPropObject ? props[key][valueKey] : valueKey;
if (isSignal(signal)) {
const keyOrIndex = isPropObject ? valueKey.toString() : valueIndex;

props[key] = isPropObject
? Object.assign({}, props[key], { [keyOrIndex]: signal.peek() })
: props[key].map((v: SignalLike, i: number) =>
i === valueIndex ? [signal.peek(), i] : v,
);
function processProp(propValue: any, path: string[]): any {
const [topLevelKey, ...restPath] = path;

const currentMap = (map.get(key) || []) as SignalToKeyOrIndexMap;
map.set(key, [...currentMap, [signal, keyOrIndex]]);
if (Array.isArray(propValue)) {
const newArr = propValue.map((value, index) =>
processProp(value, [topLevelKey, ...restPath, index.toString()]),
);
if (!signals[topLevelKey]) {
signals[topLevelKey] = {};
}
return newArr;
} else if (typeof propValue === 'object' && propValue !== null && !isSignal(propValue)) {
const newObj: Record<string, any> = {};
for (const [key, value] of Object.entries(propValue)) {
newObj[key] = processProp(value, [topLevelKey, ...restPath, key]);
}
if (!signals[topLevelKey]) {
signals[topLevelKey] = {};
}
return newObj;
} else if (isSignal(propValue)) {
const signalId = getSignalId(ctx, propValue);
const nestedPath = restPath.join('.');

const currentSignals = (signals[key] || []) as ArrayObjectMapping;
signals[key] = [...currentSignals, [getSignalId(ctx, signal), keyOrIndex]];
if (restPath.length > 0) {
if (!signals[topLevelKey]) {
signals[topLevelKey] = {};
}
});
} else if (isSignal(value)) {
// Set the value to the current signal value
// This mutates the props on purpose, so that it will be serialized correct.
props[key] = value.peek();
map.set(key, value);
if (typeof signals[topLevelKey] !== 'string') {
signals[topLevelKey][nestedPath] = signalId;
}
} else {
signals[topLevelKey] = signalId;
}

map.set(path.join('.'), propValue);

signals[key] = getSignalId(ctx, value);
return propValue.peek();
}
return propValue;
}

for (const [key, value] of Object.entries(props)) {
// Set the value to the current signal value
// This mutates the props on purpose, so that it will be serialized correct.
props[key] = processProp(value, [key]);
}

if (Object.keys(signals).length) {
if (Object.keys(signals).length > 0) {
attrs['data-preact-signals'] = JSON.stringify(signals);
}
}
Expand Down
7 changes: 2 additions & 5 deletions packages/integrations/preact/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@ export type SignalLike = {
peek(): any;
};

export type ArrayObjectMapping = [string, number | string][];
export type Signals = Record<string, string | ArrayObjectMapping>;

export type SignalToKeyOrIndexMap = [SignalLike, number | string][];
export type PropNameToSignalMap = Map<string, SignalLike | SignalToKeyOrIndexMap>;
export type Signals = Record<string, Record<string, string> | string>;
export type PropNameToSignalMap = Map<string, SignalLike>;

export type AstroPreactAttrs = {
['data-preact-signals']?: string;
Expand Down
Loading