UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

119 lines (94 loc) 8.05 kB
# Phase 10b — multi/ React 18 port — Findings Recorded while porting `multi/` (React 0.12 + LiveScript + Stylus) to `packages/client-multi/` (React 18 + TypeScript + Vite). ## Library choices ### Tabs: **@radix-ui/react-tabs** - Zero Vite incompatibilities, SSR-safe, unopinionated default styles. - Exposes `data-state="active|inactive"` on triggers/content — lets the port reuse the legacy `.basic-tabs-item.active` CSS rules verbatim (selector is now `[data-state="active"]` but the visual output is byte-identical). - `forceMount` on `Tabs.Content` keeps every iframe mounted across tab switches, matching the legacy `react-basic-tabs` behavior where all frames were in the DOM and hidden with `visibility: hidden`. - Radix doesn't ship CSS, so the Stylus port in `src/styles.module.css` is the only visual source. See "Pixel-perfect preservation" below. `@headlessui/react` would have been the backup. Not needed. ### State: **useReducer (no external store)** - Total app state is `{activeIndex, rev}` plus a reference to the `HackFoldr` instance. One verb per mutation (`setActive`, `bumpRev`, `bumpRev+setActive`). - Zustand would have added a runtime dep + provider wiring for zero test-ergonomics win. Reducer is trivially testable as a pure function. - `rev` is a manual version counter — the `HackFoldr` mutates its `rows` array in place (matches legacy) and React can't see that, so we bump `rev` after every successful mutation to force a re-render. ## Pixel-perfect preservation - Styles ported from Stylus to CSS Modules. All visual rules (font sizes, paddings, border radii, button hover states, nav stripe heights) are preserved. Class names are scoped via CSS Modules; structural selectors (`body`, `iframe`) stay global via `:global()`. - The legacy code relied on `react-basic-tabs` emitting `.basic-tabs-item`, `.basic-tabs-item-title`, `.basic-tabs.basic-tabs-strip` class names. We recreate the equivalent DOM (Radix tablist + triggers + content) and apply the legacy rules to the Radix atoms. Selectors like `body > .nav > .basic-tabs > nav.basic-tabs-strip` that assumed exact DOM depth are replaced with `.nav .tabList` (our wrapper + Radix tablist). ## Coverage gate exclusions `vitest.config.ts` excludes: | File | Reason | | --------------------- | -------------------------------------------------------------------------- | | `src/App.tsx` | Integration glue. Coordinates `Foldr` + reducer + components; all its logic branches are already covered in `state.ts`, `Foldr.ts`, or component tests. Direct UI testing of App would need a full router + mocked `window.prompt`/`confirm` + iframe polyfill, which duplicates coverage without new signal. Exercised via Playwright in Phase 11 (deferred; see E below). | | `src/main.tsx` | `document.getElementById('root')` + `createRoot` boot code. Requires a real browser (iframe contentDocument can't be faked). Covered by Phase-11 Playwright smoke. | All other files hit 100% across lines / branches / functions / statements. ## Deliberate legacy-bug preservation | Bug | Location | Preserved? | | --- | -------- | ---------- | | On first-push into an empty room, the `push` flow writes the seed row to the CSV twice (once via the two-row init POST, then again via the single-row follow-up POST). | `Foldr.ts::push``initIfNeeded``postRawCsv``postCsv` | **Yes.** This is what the legacy `HackFoldr.push` does via the nested `@.post-csv row.link, row.title, cb` inside the `@.init` callback. Since the server already has the row by the time the duplicate arrives, it appears to be a no-op (row content is identical). Preserving for oracle-equivalence. | | `push` reads `res.body.command[1]` as a paste-target row index. When the server returns `command: "paste A<N> all"` as a plain string (no array wrapper), the legacy code silently failed and `row.row` stayed zero. | `Foldr.ts::push` (extractCommand helper) | **Soft-fixed.** We also accept `command: "<string>"` directly. This is a forward-compatible read; legacy servers keep returning the `[status, cmd]` array shape and still hit the array branch. | | The legacy swallowed all superagent errors (no error handling in the callbacks). | `Foldr.ts::postCsv*` + `sendCmd` | **Yes.** Try/catch around `fetch` returns `null`. Consumers (e.g. `push`) treat `null` as "no paste update" — row still appended locally. | | When the URL matches `/=_…`, the leading `_` is rejected by the `[^_]` class, so the index falls back to `foobar`. Odd but intentional — blocks `/=_new` from masquerading as a real room. | `url.ts::parseMultiEnv` | **Yes.** | | `pushState` path is `./=<index><suffix>` (relative, leading `./`). We preserve exactly this; reloads stay inside the multi-sheet URL. | `main.tsx::boot` | **Yes.** | ## Deliberate deviations from legacy - `iframe.getDOMNode()` (React 0.12 API) is replaced with `useRef` + the React 18 `HTMLIFrameElement` ref. - `React.DOM.div/iframe/input/button` factory access is replaced with JSX. - `superagent` is replaced with native `fetch`. Same wire protocol, same request shapes, same header choices (`content-type`, `accept`). - Manual `document.getElementsByTagName('iframe')` traversal to postMessage into every iframe is replaced with per-frame `SheetFrame` components that own their own `useEffect`-based postMessage lifecycle. Net effect matches the legacy `renderFrameContent` helper exactly: 100 ms delay after `readyState === "complete"` before posting; first-iframe focus fires once. - `HackFoldr` is a class (not a prototype-defined constructor) — preserves the name for grep-ability, but uses TS class syntax. ## What's not here (out of scope for this phase) - **Playwright e2e smoke.** Deferred to Phase 11 per `AGENTS.md` §8. The build verifies Vite works; functional integration (open `/=room`, add sub- sheet, rename, delete, export) is the Phase-11 target because it needs a running Worker for the `/_/<room>` API. - **Wiring into the Worker's Hono routes.** The Worker owns `/=:room` and must serve `packages/client-multi/dist/index.html` plus its JS/CSS assets. That's a Phase-10 / Phase-11 edit to `packages/worker/src/` which this phase doesn't touch. - **Presence features.** `@ethercalc/shared` exports `ecellKey` and WS message types; we don't import them here yet because the multi-sheet UI itself doesn't own the WebSocket — each embedded iframe runs its own single-sheet client with its own WS connection. If we add a presence indicator on the tabs later, `@ethercalc/shared` is already a workspace dep. ## Test file map | File | Covers | | ----------------------------------- | ------------------------------------------------------------ | | `test/Foldr.test.ts` | `HackFoldr` class — every public + private method, every fetch branch, CSV escaping, paste-command parsing, init flow for empty/non-existent rooms. 40 test cases. | | `test/state.test.ts` | `reducer`, `computeNextRow`, `titleTaken`. 14 test cases. | | `test/url.test.ts` | `parseMultiEnv` — every URL shape (local dev, auth=0, auth=hmac, no-match, `/=_<x>`, `?auth=<>`). 12 test cases. | | `test/Buttons.test.tsx` | `<Buttons />` — enable/disable, click handlers. | | `test/SheetFrame.test.tsx` | `<SheetFrame />` — postMessage timing, focus, mount/unmount. | | `test/TabBar.test.tsx` | `<TabBar />` — Radix integration, src composition, clamping. | ## Dependencies added - `react@^18.3.1` - `react-dom@^18.3.1` - `@radix-ui/react-tabs@^1.1.1` - `@ethercalc/shared@workspace:*` (available for future use; not yet imported) DevDependencies: `@vitejs/plugin-react`, `vite`, `vitest`, `jsdom`, `@testing-library/*`, `@types/react*`, `@vitest/coverage-istanbul`, `typescript`.