mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
fix(spoolbuddy): resolve react-simple-keyboard interop default so the kiosk keyboard renders (#2616)
Focusing any text field on a SpoolBuddy screen (inventory Search, or the Search / Color Name / Brand fields on write-tag New Spool) blanked the UI with React error #130 ("Element type is invalid ... but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. The SpoolBuddy shell mounts VirtualKeyboard, an on-screen keyboard that pops up on focusin for any input -- so every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. VirtualKeyboard imports the default export of react-simple-keyboard, a CommonJS package; under the current bundler's CJS->ESM interop that default resolves to the module namespace object ({ KeyboardReact, default }) rather than the component, so <Keyboard> renders an object as an element type and React throws. vitest's interop returns the real component, so it only manifested in the browser build -- a runtime, not a type, problem. Add a small resolveInteropDefault helper that unwraps such an interop-wrapped default: it returns the value as-is when already a usable element type (function/class, tag string, or a $$typeof-marked forwardRef/memo/lazy) and otherwise falls through to .default and named exports. VirtualKeyboard resolves the real component through it.
This commit is contained in:
parent
258db95483
commit
585b1be054
7 changed files with 294 additions and 152 deletions
|
|
@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
|
||||
|
||||
### Fixed
|
||||
- **Every SpoolBuddy screen crashed the moment a text field was focused (#2616, reporters @MartinNYHC, @agentdr8)** — Tapping the Search box on the SpoolBuddy inventory, or the Search / Color Name / Brand fields on the write-tag New Spool tab, blanked the UI with a minified React error #130 ("Element type is invalid… but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. **Root cause.** The SpoolBuddy shell mounts an on-screen keyboard (`VirtualKeyboard`) that pops up on `focusin` for any text input — which is why every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. That component does `import Keyboard from 'react-simple-keyboard'`, a CommonJS package, and under the current bundler's CJS→ESM interop the default import resolves to the module **namespace object** (`{ KeyboardReact, default }`) rather than the component itself. Rendering that object as `<Keyboard>` put an object where an element type belongs, and React threw. (The discrepancy is interop-specific: the test runner hands back the real component, so it only manifested in the browser build — which is why it needed a runtime, not a type, fix.) **Fix.** A small `resolveInteropDefault` helper unwraps such an interop-wrapped default: it returns the value as-is when it's already a usable element type (function/class, tag string, or a `$$typeof`-marked forwardRef/memo/lazy) and otherwise falls through to `.default` and named exports. `VirtualKeyboard` resolves the real `react-simple-keyboard` component through it, so the keyboard renders under any interop shape. Covered by unit tests for the resolver against the object shape, a named-only export, a forwardRef object, and a bare component, plus a render test that mounts the keyboard on input focus.
|
||||
- **The streaming overlay (`/overlay`) showed nothing in OBS when login was enabled (#2613, reporter @MartinNYHC)** — With authentication on, the overlay page worked when opened in a browser where you were already signed in, but stayed blank in OBS. The reporter suspected their Cloudflare/remote setup; it was unrelated. **Root cause.** The `/overlay/{id}` *route* renders without a login, but every piece of data it draws is auth-gated — printer status and name (`PRINTERS_READ`), one setting (`SETTINGS_READ`), and the camera stream (a camera-stream token). In your own browser those ride the JWT from local storage and the app-wide stream-token sync; OBS is a fresh browser with **no session**, so the status calls 401'd and the overlay never populated (the same would happen in any private/incognito window — remote access was never the cause). Unlike the Cam Wall (`/camwall?token=…`), the overlay had no token mode, and a long-lived token couldn't help because the JWT-gated status/settings endpoints reject it. **Fix.** The overlay is now a self-contained kiosk surface. A new **Streaming Overlay** long-lived-token scope is offered under Settings → API Keys (with a ready-made `/overlay/{id}?token=…` URL copied once on creation); the overlay page reads `?token=` from the URL and, in that mode, authenticates its status and camera calls with the token instead of a JWT (and skips the WebSocket, falling back to its existing 2 s poll). A new token-authenticated `GET /printers/{id}/overlay-status` returns exactly the fields the overlay draws — name, camera rotation, live print state, and the one setting — and nothing else. The scope is deliberately **separate from `camwall`**: the overlay names the file on screen, which the Cam Wall is trusted never to expose, so folding it in would have silently widened every existing wall token. The logged-in path (opening the overlay while signed in) is unchanged. Docs updated to explain the token and stop claiming the overlay needs no authentication. Covered by backend tests (scope boundaries in both directions — an overlay token can't reach the Cam Wall feed and a camwall/camera-stream token can't reach the overlay feed — plus the payload shape, disconnected-printer shape, and revoked/absent/garbage-token rejection) and frontend tests (kiosk mode reads the token feed and carries the token to the camera, never touches the JWT-only status endpoint or a socket; the mint UI offers the scope and hands over the assembled OBS URL).
|
||||
- **Reassigning a queue item while it was dispatching split it across two printers (#2615, reporter @Jostxxl)** — Editing a queue item's printer while its FTP upload was already in flight left the queue row pointing at one printer while the archive, expected-print registration, and the physical `project_file` command had gone to another. On a farm this made the reassigned-to printer look broken (marked `printing` but never sent the job), left the row permanently inconsistent, and could trigger a duplicate dispatch after a restart. **Root cause.** A queue row stays `status='pending'` for the entire (multi-minute) FTP upload — status only flips to `printing` at the very end. The edit route only blocked non-`pending` rows, so a `PATCH` during the upload window was accepted; the in-flight dispatch kept using the printer it had snapshotted at the start, while the DB row's `printer_id` changed underneath it. The existing #1853 CAS guards *cancellation* mid-dispatch, not *reassignment*. **Fix.** A `dispatching_at` claim is stamped atomically on the row (`WHERE status='pending' AND dispatching_at IS NULL`) the moment the scheduler begins dispatching, before any slow I/O, and cleared when dispatch ends. While it's held, both edit routes reject changes — the single-item `PATCH` returns **409** (re-checked immediately before the write to close the read-modify-write gap), and bulk edits skip the row — and the scheduler's selection query won't re-pick it. Startup reconciliation clears any claim orphaned by a crash mid-dispatch (no dispatch coroutine survives a restart, so every claim present at boot is stale), so a stale token can never wedge an item out of the queue. The row stays `pending` throughout, so no status-consumer, UI, completion, or reconciliation path had to change. To move a dispatching item, cancel it first (the coordinated escape) and re-queue. New column `print_queue.dispatching_at` (nullable timestamp, dialect-safe DDL — SQLite `DATETIME` / Postgres `TIMESTAMP`). Covered by scheduler tests (claim is exclusive, fails on non-pending rows, releases on every exit, skips an already-claimed row, startup clears stale claims) and API tests (reassign returns 409 with `printer_id` unchanged, bulk skips the claimed row, an unclaimed pending row still edits normally).
|
||||
- **A single plate printed from a multi-plate 3MF recorded the whole file's filament in statistics (#2614, reporter @Jostxxl)** — Dispatching one selected plate of a sliced multi-plate 3MF through the queue could log the **entire file's** filament against that one plate. The reporter's `heart 3.gcode.3mf` has 22 plates totalling ~12.0 kg; every completed plate recorded `12006.49 g`, so 13 runs inflated lifetime/user/project/filament stats by ~156 kg from one file. **Root cause.** The per-run value written to `PrintLogEntry.filament_used_grams` prefers the AMS-tracked spool delta, but when the tracker measured nothing (no inventory assignment on the printer) a *completed* run fell back to `PrintArchive.filament_used_grams` — which is deliberately the **sum over every plate** of the source 3MF (correct for the archive card and project rollup, #1593). The archive's `plate_id` (persisted by #2603) was never consulted on this path, so the whole-file total was copied verbatim; `cost` had the same defect, falling back to the whole-file `archive.cost`. **Forward fix.** When the archive carries a `plate_id` and its 3MF is on disk, the completed-run fallback now uses that plate's own slicer estimate (`extract_plate_metadata_from_3mf`, the same plate-scoped parse the inventory tracker uses), and scales cost by the plate's share of the whole. The tracker-measured path is unchanged (measured spool deltas still win) and single-plate archives are unaffected (plate value equals the whole-file value). **Backfill.** A startup migration repairs rows already written: for completed print-log entries whose stored grams **exactly equal** the linked archive's whole-file value (the mis-copy signature) and whose archive has a `plate_id` and an on-disk 3MF, it recomputes the plate-scoped grams + cost. The exact-match guard means tracker-measured rows (a rounded spool-delta sum) and partial-progress rows (scaled to progress) are never touched; it's idempotent (a corrected row no longer matches) and data-only, identical on SQLite and Postgres. Logs how many rows and how many grams of over-count it removed. Covered by unit tests for the forward helper (plate scoping, cost scaling, fallbacks when there's no plate_id / no file / unreadable estimate) and the backfill (mis-copy repaired, tracker/partial rows untouched, missing-3MF skipped, single-plate not relabelled, idempotent).
|
||||
|
|
|
|||
43
frontend/src/__tests__/components/VirtualKeyboard.test.tsx
Normal file
43
frontend/src/__tests__/components/VirtualKeyboard.test.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Regression for #2616. react-simple-keyboard ships as CommonJS; under the
|
||||
* bundler's CJS interop the default import can arrive as the module namespace
|
||||
* object rather than the Keyboard component, so rendering <Keyboard> throws
|
||||
* React #130 ("Element type is invalid ... got: object"). The on-screen keyboard
|
||||
* mounts on every SpoolBuddy screen the instant a text input is focused, so the
|
||||
* crash hit inventory search and the write-tag New Spool fields alike.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { VirtualKeyboard } from '../../components/VirtualKeyboard';
|
||||
|
||||
// focusin schedules a 100ms scrollIntoView on the focused input; jsdom doesn't
|
||||
// implement it, so stub it or the deferred call throws an unhandled error after
|
||||
// the test completes.
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('VirtualKeyboard (#2616)', () => {
|
||||
it('renders the keyboard when a text input is focused (no invalid-element-type crash)', () => {
|
||||
render(
|
||||
<div>
|
||||
<input type="text" placeholder="Search spools..." />
|
||||
<VirtualKeyboard />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search spools...');
|
||||
// The shell listens on document focusin, so drive a real focus event.
|
||||
fireEvent.focusIn(input);
|
||||
|
||||
// A key from the layout must be on screen — proves <Keyboard> resolved to a
|
||||
// real component instead of throwing on an object element type.
|
||||
expect(screen.getByText('q')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
46
frontend/src/__tests__/utils/interopDefault.test.ts
Normal file
46
frontend/src/__tests__/utils/interopDefault.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Unit tests for resolveInteropDefault (#2616).
|
||||
*
|
||||
* The browser build resolved react-simple-keyboard's CommonJS default import to
|
||||
* the module namespace object ({ KeyboardReact, default }) instead of the
|
||||
* component, so <Keyboard> threw React #130 ("got: object"). vitest's own interop
|
||||
* happens to hand back the component, so a render test can't catch the
|
||||
* regression — these assert the resolver directly against both shapes.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveInteropDefault } from '../../utils/interopDefault';
|
||||
|
||||
const Comp = function Keyboard() {
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('resolveInteropDefault', () => {
|
||||
it('returns a bare function component unchanged', () => {
|
||||
expect(resolveInteropDefault(Comp)).toBe(Comp);
|
||||
});
|
||||
|
||||
it('unwraps the CJS interop namespace object via .default (the #2616 shape)', () => {
|
||||
const moduleObject = { default: Comp, KeyboardReact: Comp };
|
||||
expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
|
||||
});
|
||||
|
||||
it('falls back to a named export when there is no .default', () => {
|
||||
const moduleObject = { KeyboardReact: Comp };
|
||||
expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
|
||||
});
|
||||
|
||||
it('leaves a forwardRef/memo object (with $$typeof) untouched', () => {
|
||||
const forwardRefLike = { $$typeof: Symbol.for('react.forward_ref'), render: Comp };
|
||||
expect(resolveInteropDefault(forwardRefLike)).toBe(forwardRefLike);
|
||||
});
|
||||
|
||||
it('returns a string tag unchanged', () => {
|
||||
expect(resolveInteropDefault('div')).toBe('div');
|
||||
});
|
||||
|
||||
it('returns the value unchanged when nothing usable is found', () => {
|
||||
const opaque = { something: 1 };
|
||||
expect(resolveInteropDefault(opaque, ['KeyboardReact'])).toBe(opaque);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,20 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import Keyboard from 'react-simple-keyboard';
|
||||
import KeyboardImport from 'react-simple-keyboard';
|
||||
import 'react-simple-keyboard/build/css/index.css';
|
||||
import './VirtualKeyboard.css';
|
||||
import { resolveInteropDefault } from '../utils/interopDefault';
|
||||
|
||||
// react-simple-keyboard is published as CommonJS. Depending on the bundler's
|
||||
// CJS->ESM interop, the default import arrives either as the Keyboard component
|
||||
// itself or as the module namespace object ({ KeyboardReact, default }). Under
|
||||
// the current Vite build (and Node's ESM loader) it's the latter, so rendering
|
||||
// <KeyboardImport> puts an object where an element type belongs and React throws
|
||||
// "Element type is invalid ... got: object" (#130) — crashing every SpoolBuddy
|
||||
// screen the instant a text input is focused and this keyboard mounts (#2616).
|
||||
// Resolve the real component defensively so it renders under any interop shape.
|
||||
// The TYPE of the default import is already the component (from the .d.ts), so
|
||||
// the cast keeps JSX + ref typing intact while fixing only the runtime value.
|
||||
const Keyboard = resolveInteropDefault<typeof KeyboardImport>(KeyboardImport, ['KeyboardReact']);
|
||||
|
||||
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
|
||||
|
||||
|
|
|
|||
39
frontend/src/utils/interopDefault.ts
Normal file
39
frontend/src/utils/interopDefault.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Unwrap a default import that a bundler's CommonJS->ESM interop may have
|
||||
* wrapped in a module namespace object.
|
||||
*
|
||||
* Some CommonJS packages set `module.exports = { default: X, Named: X }`.
|
||||
* Depending on the bundler (and differing between the browser build, the test
|
||||
* runner, and Node's own ESM loader), `import X from 'pkg'` can hand you that
|
||||
* whole object instead of `X`. Rendering such an object as a React component
|
||||
* throws "Element type is invalid ... got: object" (React error #130) — see
|
||||
* #2616, where react-simple-keyboard's default import arrived as the namespace
|
||||
* object and crashed every SpoolBuddy screen on input focus.
|
||||
*
|
||||
* This returns the value unchanged when it is already a usable React element
|
||||
* type (a function/class component, a tag string, or an object carrying a React
|
||||
* `$$typeof` marker such as forwardRef/memo/lazy). Otherwise it tries `.default`
|
||||
* and then each of `fallbackKeys` in order, returning the first usable one, and
|
||||
* finally falls back to the original value.
|
||||
*/
|
||||
export function resolveInteropDefault<T = unknown>(value: unknown, fallbackKeys: string[] = []): T {
|
||||
if (isRenderableType(value)) return value as T;
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (isRenderableType(obj.default)) return obj.default as T;
|
||||
for (const key of fallbackKeys) {
|
||||
if (isRenderableType(obj[key])) return obj[key] as T;
|
||||
}
|
||||
}
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/** True when `v` is something React can render as an element type. */
|
||||
function isRenderableType(v: unknown): boolean {
|
||||
if (typeof v === 'function' || typeof v === 'string') return true;
|
||||
// forwardRef / memo / lazy / context objects are valid element types and are
|
||||
// distinguished from a plain interop wrapper by their React `$$typeof` marker.
|
||||
return typeof v === 'object' && v !== null && '$$typeof' in v;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BLUpUiDA.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-Cqi3-E-p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue