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
9 changes: 7 additions & 2 deletions apps/web/src/components/PageComponents/Settings/LoRa.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useWaitForConfig } from "@app/core/hooks/useWaitForConfig";
import {
type LoRaValidation,
LoRaValidationSchema,
withLoRaDefaults,
} from "@app/validation/config/lora.ts";
import {
DynamicForm,
Expand Down Expand Up @@ -29,11 +30,15 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
const editor = useConfigEditor();
const radio = useSignal(editor?.radio ?? EMPTY_RADIO_SIGNAL);

const effectiveLora =
const effectiveLoraConfig =
radio.lora ??
(getEffectiveConfig("lora") as
| Protobuf.Config.Config_LoRaConfig
| undefined);
const effectiveLora = effectiveLoraConfig
? withLoRaDefaults(effectiveLoraConfig)
: undefined;
const defaultLora = config.lora ? withLoRaDefaults(config.lora) : undefined;

const { t } = useTranslation("config");

Expand All @@ -50,7 +55,7 @@ export const LoRa = ({ onFormInit }: LoRaConfigProps) => {
onSubmit={onSubmit}
onFormInit={onFormInit}
validationSchema={LoRaValidationSchema}
defaultValues={config.lora}
defaultValues={defaultLora}
values={effectiveLora}
fieldGroups={[
{
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/core/hooks/useWaitForConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { create } from "@bufbuild/protobuf";
import { CurrentDeviceContext } from "@core/hooks/useDeviceContext.ts";
import { useDeviceStore } from "@core/stores/deviceStore/index.ts";
import { Protobuf } from "@meshtastic/sdk";
import { act, renderHook } from "@testing-library/react";
import type { PropsWithChildren } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { useWaitForConfig } from "./useWaitForConfig.ts";

const DEVICE_ID = 4242;

const wrapper = ({ children }: PropsWithChildren) => (
<CurrentDeviceContext.Provider value={{ deviceId: DEVICE_ID }}>
{children}
</CurrentDeviceContext.Provider>
);

describe("useWaitForConfig", () => {
beforeEach(() => {
useDeviceStore.getState().removeDevice(DEVICE_ID);
});

it("resolves its suspense promise when the requested config arrives", async () => {
const device = useDeviceStore.getState().addDevice(DEVICE_ID);
const { result } = renderHook(
() => {
try {
useWaitForConfig({ configCase: "lora" });
return "ready" as const;
} catch (pending) {
return pending as Promise<void>;
}
},
{ wrapper },
);

expect(result.current).toBeInstanceOf(Promise);
const pending = result.current as Promise<void>;

act(() => {
device.setConfig(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: create(Protobuf.Config.Config_LoRaConfigSchema, {}),
},
}),
);
});

const outcome = await Promise.race([
pending.then(() => "resolved"),
new Promise<string>((resolve) =>
setTimeout(() => resolve("timed-out"), 50),
),
]);
expect(outcome).toBe("resolved");
});
});
25 changes: 23 additions & 2 deletions apps/web/src/core/hooks/useWaitForConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
useDevice,
useDeviceStore,
type ValidConfigType,
type ValidModuleConfigType,
} from "@core/stores";
Expand All @@ -12,13 +13,33 @@ export function useWaitForConfig({
configCase,
moduleConfigCase,
}: UseWaitForConfigProps): void {
const { config, moduleConfig } = useDevice();
const device = useDevice();
const { config, moduleConfig } = device;

const isDataDefined = configCase
? config[configCase] !== undefined
: moduleConfig[moduleConfigCase as ValidModuleConfigType] !== undefined;

if (!isDataDefined) {
throw new Promise<void>(() => {});
throw new Promise<void>((resolve) => {
const hasRequestedConfig = (): boolean => {
const current = useDeviceStore.getState().getDevice(device.id);
if (!current) return false;
return configCase
? current.config[configCase] !== undefined
: current.moduleConfig[moduleConfigCase as ValidModuleConfigType] !==
undefined;
};

let unsubscribe = (): void => {};
const check = (): void => {
if (hasRequestedConfig()) {
unsubscribe();
resolve();
}
};
unsubscribe = useDeviceStore.subscribe(check);
check();
Comment on lines +24 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map and inspect the target file and related store APIs.
printf '\n== useWaitForConfig outline ==\n'
ast-grep outline apps/web/src/core/hooks/useWaitForConfig.ts --view expanded || true

printf '\n== useWaitForConfig full file (numbered) ==\n'
cat -n apps/web/src/core/hooks/useWaitForConfig.ts

printf '\n== Search for subscribe/getState usages around device store ==\n'
rg -n "useDeviceStore\.(subscribe|getState)|create\(.*useDeviceStore|subscribe\(" apps/web/src -g '!**/node_modules/**' || true

printf '\n== Locate device store definition ==\n'
fd -a "useDeviceStore.*" apps/web/src || true
fd -a "*device*store*" apps/web/src || true

Repository: meshtastic/web

Length of output: 9848


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== deviceStore subscribe implementation outline ==\n'
ast-grep outline apps/web/src/core/stores/deviceStore/index.ts --view expanded || true

printf '\n== relevant subscribe/getState sections in deviceStore ==\n'
rg -n "subscribe\\(|getState\\(|create\\(" apps/web/src/core/stores/deviceStore/index.ts || true

printf '\n== deviceStore slices around subscribe implementation ==\n'
# File seems large; inspect only relevant ranges found by rg if possible.
sed -n '760,860p' apps/web/src/core/stores/deviceStore/index.ts

printf '\n== useWaitForConfig test file ==\n'
cat -n apps/web/src/core/hooks/useWaitForConfig.test.tsx

printf '\n== useFeatureFlags hook for comparison ==\n'
cat -n apps/web/src/core/hooks/useFeatureFlags.ts

Repository: meshtastic/web

Length of output: 9684


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== useWaitForConfig usages ==\n'
rg -n "useWaitForConfig\\(" apps/web/src -g '!**/node_modules/**' || true

printf '\n== nearby contexts for those usages ==\n'
for f in $(rg -l "useWaitForConfig\\(" apps/web/src -g '!**/node_modules/**' || true); do
  printf '\n--- %s ---\n' "$f"
  rg -n -A6 -B6 "useWaitForConfig\\(" "$f"
done

printf '\n== search for Suspense wrappers in settings/UI flows ==\n'
rg -n "<Suspense|Suspense\\s*from" apps/web/src/pages apps/web/src/components apps/web/src/core -g '!**/node_modules/**' || true

Repository: meshtastic/web

Length of output: 18581


Dispose suspended waiters. Each suspended render creates a new global store subscription, and there’s no cleanup if the component unmounts before config arrives. Leaving Settings pending—or re-rendering while still waiting—can accumulate listeners on the device store.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/core/hooks/useWaitForConfig.ts` around lines 24 - 42, Update the
suspended waiter in useWaitForConfig so each store subscription is disposed when
the waiting component unmounts or the suspended render is abandoned, while still
resolving and unsubscribing when hasRequestedConfig returns true. Ensure
repeated pending renders do not leave accumulated listeners on useDeviceStore.

});
}
}
17 changes: 14 additions & 3 deletions apps/web/src/pages/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ConfigPage = () => {
);

const [isSaving, setIsSaving] = useState(false);
const [formResetKey, setFormResetKey] = useState(0);
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
const unsubRef = useRef<(() => void) | null>(null);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
Expand Down Expand Up @@ -163,10 +164,15 @@ const ConfigPage = () => {
}, [toast, t, formMethods, editor]);

const handleReset = useCallback(() => {
if (formMethods) {
if (editor) {
// The editor is the source of the controlled `values` passed to each
// form. Remount the active form after discarding drafts so a controlled
// value update cannot emit a late change and recreate the draft.
editor.reset();
setFormResetKey((key) => key + 1);
} else if (formMethods) {
formMethods.reset();
}
editor?.reset();
}, [formMethods, editor]);

const leftSidebar = useMemo(
Expand Down Expand Up @@ -258,7 +264,12 @@ const ConfigPage = () => {
label={activeSection?.label ?? ""}
actions={actions}
>
{ActiveComponent && <ActiveComponent onFormInit={onFormInit} />}
{ActiveComponent && (
<ActiveComponent
key={`${activeSection.key}:${formResetKey}`}
onFormInit={onFormInit}
/>
)}
</PageLayout>
);
};
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/validation/config/lora.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/sdk";
import { describe, expect, it } from "vitest";
import { withLoRaDefaults } from "./lora.ts";

describe("LoRa modem presets", () => {
it("includes LONG_TURBO modem preset", () => {
Expand All @@ -11,3 +13,13 @@ describe("LoRa modem presets", () => {
).toBe(true);
});
});

describe("LoRa validation", () => {
it("defaults an omitted serialHalOnly form value to false", () => {
const { serialHalOnly: _, ...legacyConfig } = create(
Protobuf.Config.Config_LoRaConfigSchema,
);

expect(withLoRaDefaults(legacyConfig).serialHalOnly).toBe(false);
});
});
9 changes: 9 additions & 0 deletions apps/web/src/validation/config/lora.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@ export const LoRaValidationSchema = z.object({
});

export type LoRaValidation = z.infer<typeof LoRaValidationSchema>;

export function withLoRaDefaults<T extends { serialHalOnly?: boolean }>(
config: T,
): T & { serialHalOnly: boolean } {
return {
...config,
serialHalOnly: config.serialHalOnly ?? false,
};
}
62 changes: 53 additions & 9 deletions packages/sdk/src/core/client/MeshClient.progress.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { create } from "@bufbuild/protobuf";
import { create, fromBinary } from "@bufbuild/protobuf";
import * as Protobuf from "@meshtastic/protobufs";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { createFakeTransport } from "../testing/createFakeTransport.ts";
import { MeshClient } from "./MeshClient.ts";

describe("MeshClient.progress", () => {
const CONFIG_ONLY_NONCE = 69420;
const NODES_ONLY_NONCE = 69421;

it("starts in the idle phase before configure() is called", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
Expand Down Expand Up @@ -60,21 +63,63 @@ describe("MeshClient.progress", () => {
expect(cur.received.modules).toBe(0);
});

it("flips to configured when onConfigComplete fires", () => {
const { transport } = createFakeTransport();
it("requests config and nodes in separate firmware-compatible stages", async () => {
const { transport, respond, sent } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();

await client.configure();

const initialPackets = sent.map(
(packet) =>
fromBinary(Protobuf.Mesh.ToRadioSchema, packet).payloadVariant,
);
expect(initialPackets[0]?.case).toBe("heartbeat");
expect(initialPackets[1]).toEqual({
case: "wantConfigId",
value: CONFIG_ONLY_NONCE,
});

client.events.onConfigPacket.dispatch(
create(Protobuf.Config.ConfigSchema, {}),
);
client.events.onConfigComplete.dispatch(0);
respond.withConfigCompleteId(CONFIG_ONLY_NONCE);

await vi.waitFor(() => {
expect(sent).toHaveLength(3);
});
expect(
fromBinary(Protobuf.Mesh.ToRadioSchema, sent[2]!).payloadVariant,
).toEqual({ case: "wantConfigId", value: NODES_ONLY_NONCE });
expect(client.progress.value.phase).toBe("configuring");

respond.withConfigCompleteId(NODES_ONLY_NONCE);

await vi.waitFor(() => {
expect(client.progress.value.phase).toBe("configured");
});

const cur = client.progress.value;
expect(cur.phase).toBe("configured");
if (cur.phase !== "configured") throw new Error("unreachable");
expect(cur.received.config).toBe(1);
});

it("ignores stale and out-of-order config completion nonces", async () => {
const { transport, respond, sent } = createFakeTransport();
const client = new MeshClient({ transport });
const completed: number[] = [];
client.events.onConfigComplete.subscribe((id) => completed.push(id));

await client.configure();
respond.withConfigCompleteId(NODES_ONLY_NONCE);
respond.withConfigCompleteId(12345);

await new Promise((resolve) => setTimeout(resolve, 20));
expect(sent).toHaveLength(2);
expect(client.progress.value.phase).toBe("configuring");
expect(completed).toEqual([]);
Comment on lines +113 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File outline ==\n'
ast-grep outline packages/sdk/src/core/client/MeshClient.progress.test.ts --view expanded || true

printf '\n== Relevant test section ==\n'
sed -n '1,220p' packages/sdk/src/core/client/MeshClient.progress.test.ts

printf '\n== Search for handleConfigComplete and related logic ==\n'
rg -n "handleConfigComplete|configComplete|progress.value.phase|configure\(" packages/sdk/src/core/client -S

Repository: meshtastic/web

Length of output: 7973


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/sdk/src/core/client/MeshClient.progress.test.ts
printf '\n---\n'
rg -n "handleConfigComplete|configComplete|progress\\.value\\.phase|configure\\(" packages/sdk/src/core/client -S

Repository: meshtastic/web

Length of output: 7801


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== MeshClient.ts outline ==\n'
ast-grep outline packages/sdk/src/core/client/MeshClient.ts --view expanded || true

printf '\n== Relevant MeshClient.ts section ==\n'
sed -n '150,260p' packages/sdk/src/core/client/MeshClient.ts

printf '\n== Fake transport ==\n'
sed -n '1,260p' packages/sdk/src/core/testing/createFakeTransport.ts

Repository: meshtastic/web

Length of output: 10526


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Remaining MeshClient.ts section ==\n'
sed -n '236,340p' packages/sdk/src/core/client/MeshClient.ts

printf '\n== Decode packet path ==\n'
rg -n "function decodePacket|const decodePacket|decodePacket\\(" packages/sdk/src/core/client packages/sdk/src/core -S

printf '\n== fromDeviceStream / toDeviceStream ==\n'
sed -n '1,220p' packages/sdk/src/core/packet-codec/fromDevice.ts
printf '\n---\n'
sed -n '1,220p' packages/sdk/src/core/packet-codec/toDevice.ts

Repository: meshtastic/web

Length of output: 6821


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== decodePacket.ts ==\n'
sed -n '1,220p' packages/sdk/src/core/packet-codec/decodePacket.ts

printf '\n== transport types ==\n'
sed -n '1,220p' packages/sdk/src/core/transport/Transport.ts

printf '\n== queue / event bus hooks used by config complete ==\n'
rg -n "onConfigComplete|dispatch\\(|handleConfigComplete\\(" packages/sdk/src/core -S

Repository: meshtastic/web

Length of output: 16612


Wait for config-complete delivery before asserting ignored nonces. The 20 ms sleep is brittle here; wait on handleConfigComplete/completed instead so both packets are processed before checking sent and completed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/sdk/src/core/client/MeshClient.progress.test.ts` around lines 113 -
120, Update the test around client.configure and the config-complete responses
to await completion through handleConfigComplete or the completed collection
instead of using the fixed 20 ms timeout. Ensure both config-complete packets
are processed before asserting sent, progress.value.phase, and completed, while
preserving the expected ignored-nonce results.

});

it("ignores packets that arrive while idle (post-completion)", () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
Expand All @@ -85,14 +130,13 @@ describe("MeshClient.progress", () => {
expect(client.progress.value.phase).toBe("idle");
});

it("resets counters when configure() runs again", () => {
it("resets counters when configure() runs again", async () => {
const { transport } = createFakeTransport();
const client = new MeshClient({ transport });
void client.configure();
await client.configure();
client.events.onConfigPacket.dispatch(
create(Protobuf.Config.ConfigSchema, {}),
);
client.events.onConfigComplete.dispatch(0);
void client.configure();
expect(client.progress.value).toEqual({
phase: "configuring",
Expand Down
Loading
Loading