80fb91c033dfd367f17ba58e2095526faa016e70
7 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
308e4de7a6 |
TDD: twenty-ui render coverage in the front-component sandbox — golden tests cataloging every sandbox gap (fully green) (#23203)
## What — TDD coverage layer, no fixes Renders every twenty-ui component export inside the real front-component sandbox (opaque-origin iframe + remote-dom worker, real SDK esbuild pipeline) through the existing Playwright-backed storybook vitest suite, and expresses **every sandbox gap as a golden known-failure test**. This PR deliberately ships **zero fixes** and is **fully green (267/267)**: each known issue has a test asserting the exact current broken behavior. A golden test fails on regression (an unexpected component starts failing) AND on fix (the documented failure disappears) — so every future fix must flip its golden assertion to the strict one, whose acceptance criteria are spelled out in each story's comment. - One gallery fixture per submodule under `src/__stories__/twenty-ui-gallery/` (~150 components) - `component-gallery.tsx`: each component renders inside its own error boundary; failures are aggregated with component name and error message (`data-failed-messages`) so one crash cannot mask the rest - Behavior-level stories that a fake fix cannot pass (MutationObserver must actually fire; a link click must reach `hostApi.navigate`) - `vitest.config.ts` re-export so the storybook in-UI "Run tests" button works ## Current state: 267/267 green — mergeable; 18 golden tests encode the catalog below ## Failure catalog (each entry = golden tests asserting today's broken behavior) ### 1. `MutationObserver.observe is not a function` — 6 failing tests `@remote-dom/polyfill` ships `MutationObserver` as an empty class: construction succeeds, the first `.observe()` call crashes. | Failing test | twenty-ui export(s) | Call site | |---|---|---| | Input React + Preact | `Radio`, `RadioGroup`, `CardPicker` | `@base-ui/react` field internals observe form state | | Surfaces React + Preact | `AppTooltip` | twenty-ui observes `document.body` to track anchors | | Worker Platform APIs: Mutation Observer React + Preact | (behavior test) | asserts the observer actually fires on a React-driven insertion — a no-op stub cannot pass it | Validated fix (branch history, commit 94b96d01e79e): implement real mutation semantics **locally in the worker** by tapping the same `@remote-dom/polyfill` hooks (`insertChild`/`removeChild`/`setAttribute`/`removeAttribute`/`setText`) remote-dom already uses to mirror mutations to the host. The worker tree is the source of truth — no host bridge needed. ### 2. `getComputedStyle is not a function` — 4 failing tests The remote-dom `Window` polyfill does not implement `getComputedStyle`; `@base-ui/react` Collapsible calls it on open. | Failing test | twenty-ui export(s) | |---|---| | Layout React + Preact | `AnimatedEaseInOut`, `AnimatedExpandableContainer` | | Json Visualizer React + Preact | `JsonTree`, `JsonArrayNode`, `JsonObjectNode`, `JsonNestedNode` (all via `JsonNestedNode`'s Collapsible) | Validated fix (commit 7af577054fd9): `getComputedStyle` is synchronous and layout only exists host-side, so a bridge is impossible — an inert-but-valid stub (`0s` durations, `none` animation names) is the honest terminal state. ### 3. No router context in the sandbox — 5 failing tests react-router's `Link` reads `NavigationContext`, which has no provider inside the worker: `Cannot destructure property 'basename' of 'useContext(...)' as it is null`. | Failing test | twenty-ui export(s) | |---|---| | Navigation React + Preact | `RawLink`, `UndecoratedLink` | | Data Display React + Preact | `LinkChip` | | HostApi: Router Link | acceptance test — the link must render AND its click must reach `hostApi.navigate` | Validated fix (commit 221c9c353dc8): SDK build plugin wraps the component tree in a low-level react-router `<Router>` whose custom navigator forwards `push`/`replace` to the SDK `navigate` host API (NOT a MemoryRouter, which would render links but swallow clicks into in-memory history). Falls back to a pass-through provider when the app has no react-router-dom dependency. **Security finding bundled in that commit:** clicking a mirrored `<a>` performs a native host-page navigation before the async worker round-trip can `preventDefault` — a front component can escape the sandbox by rendering a link. Fix: apply the renderer's existing preventDefault-then-forward guard (already used for form submits) to anchor clicks, keeping `target="_blank"` native. The Router Link story's flip-to acceptance assertions (click must reach `hostApi.navigate`) will hard-crash the vitest browser the moment links render without this guard — by design. ### 4. Open `Modal` hangs the React runtime — 1 failing test base-ui Dialog portal with `isOpen` never commits under the React runtime (no error thrown); works under Preact. | Failing test | twenty-ui export | |---|---| | Modal Open React (Modal Open Preact passes) | `Modal` | Isolated in its own fixture so the hang cannot mask the rest of the surfaces gallery (which keeps a closed Modal for mount coverage). Root cause not yet identified — first experiment: portal into a worker-owned container instead of the polyfilled `document.body`. ### 5. monaco cannot load in the sandbox — 2 failing tests `@monaco-editor/react` lazy-loads monaco via script injection, impossible in the polyfilled worker DOM (opaque-origin CSP, no script loading). The CodeEditor wrapper mounts; monaco's `onMount` never fires. | Failing test | twenty-ui export | |---|---| | Code Editor React + Preact | `CodeEditor` | Probably wont-fix in the worker: if front components need a code editor, the path is a host-rendered privileged component. The failing test is the documentation. ### Also worth knowing (no failing test possible yet) - `ResizeObserver` / `IntersectionObserver` / `matchMedia` are absent in the worker: components mount without them today only because nothing crashes at mount — behavior like popup auto-resize and `useIsMobile` is silently wrong. Real fix is a host bridge (async observers on the component's own mirrored elements only — never the host document). Behavior tests should land with that fix. - `Icon`/`IconsProvider`/`useIcons` are excluded from galleries by choice: they dynamic-import the multi-MB Tabler catalog; direct icon imports are the supported pattern in bundled front components. ## Iteration plan Each fix is one commit + one set of stories flipping green, in suggested order: 1. Worker-local MutationObserver (6 tests) — cherry-pick base: 94b96d01e79e 2. `getComputedStyle` + observer/matchMedia stubs (4 tests) — cherry-pick base: 7af577054fd9 3. Router provider + anchor click guard (5 tests) — cherry-pick base: 221c9c353dc8 4. Open-Modal React hang investigation (1 test) 5. CodeEditor: decide wont-fix + keep the failing story or convert to a documented skip (2 tests) ## How to run ``` npx nx run twenty-front-component-renderer:storybook:prebuild cd packages/twenty-front-component-renderer npx vitest run --config vitest.storybook.config.ts --project storybook ``` |
||
|
|
60f5964c64 |
Run front components in a sandboxed opaque-origin iframe (#22588)
Front components run untrusted third-party React in a Web Worker. That
worker previously shared the host origin, so it could reach
origin-scoped storage (the metadata-store IndexedDB, the
`twenty-sign-out` BroadcastChannel), cookies, and same-origin resources.
This runs the worker inside a `sandbox="allow-scripts"` (no
`allow-same-origin`) iframe, giving it an opaque origin where the
browser denies localStorage, cookies, IndexedDB, and BroadcastChannel
outright. The worker is kept inside the iframe (rather than a bare
iframe) so untrusted code always runs off the main thread; the
remote-dom render path is unchanged.
- **Transport:** host ↔ iframe ↔ worker over a re-transferred
`MessagePort` (`ThreadMessagePort`); a small bootstrap script is inlined
into the iframe via `srcdoc` (bundled at build time by a prebuild step)
and relays the port to the worker it spawns. Messages across the
boundary use a typed discriminated union with a single parse/guard.
- **Network:** under the opaque origin, direct fetches to the Twenty API
would be `Origin: null`, so the component source and SDK modules are
fetched through an allowlisted, credential-omitting `hostFetch` bridge
and blobbed inside the worker. The allowlist is single-sourced on the
host (http(s) origins only) and carried in the render context. The
bridge is mandatory (rendering fails closed if it is missing), refuses
redirects except for GET/HEAD to the known file-storage URLs, and caps
response body size.
- **SDK loading:** SDK client modules now load inside the worker through
the bridge, replacing the host-side SDK-blob state/effect/provider with
a pure `getSdkClientUrls` URL builder.
- **Isolation tests:** a unit test locks the sandbox attribute
(`allow-scripts`, never `allow-same-origin`); a browser test asserts the
worker actually gets an opaque origin with storage denied, probing
cookies by writing one rather than reading an empty jar.
Also adds a "List Companies" seed front component that queries workspace
data via the SDK client (exercising the bridge end-to-end),
single-sources the command-menu confirmation-modal result event name and
detail type in `twenty-shared` (previously a hand-synced duplicate), and
decomposes the renderer (bridge, sandbox, worker orchestration) into
small single-purpose utils with unit tests.
## How it works
```mermaid
sequenceDiagram
autonumber
participant Host as Host window (twenty-front · host origin)
participant Frame as Sandboxed iframe (allow-scripts · opaque origin)
participant Worker as Worker (untrusted component · opaque origin)
participant API as Twenty API (host origin)
rect rgb(238,242,248)
Note over Host,Worker: 1 — Boot handshake
Host->>Frame: create iframe sandbox="allow-scripts", srcdoc = inlined bootstrap script
Host->>Host: MessageChannel + ThreadMessagePort(port1)<br/>exports = host API + hostFetch
Frame-->>Host: READY
Host->>Frame: INIT + transfer port2
Frame->>Worker: spawn inlined Worker + re-transfer port2
Worker->>Worker: ThreadMessagePort(port)<br/>exports = render / updateContext
Note over Host,Worker: Port now entangles Host ↔ Worker directly
end
rect rgb(246,240,248)
Note over Host,Worker: 2 — Render
Host->>Worker: render(connection, { componentUrl, sdkClientUrls, hostFetchOrigins, token })
Worker->>Worker: override globalThis.fetch<br/>(Twenty origins → hostFetch)
end
rect rgb(248,244,238)
Note over Worker,API: 3 — Network via hostFetch bridge (opaque Origin:null cannot reach the API directly)
Worker->>Host: hostFetch(componentUrl, Bearer)
Host->>Host: origin allowlist + credentials:'omit'
Host->>API: fetch(componentUrl)
API-->>Host: source
Host-->>Worker: { status, headers, body }
Worker->>Host: hostFetch(sdkClientUrls.core / .metadata)
Host-->>Worker: SDK module sources
Worker->>Worker: blob each source in its own opaque origin → import() → run untrusted React
end
rect rgb(238,248,242)
Note over Worker,Host: 4 — Render mirror
Worker->>Host: remote-dom mutations (RemoteConnection)
Host->>Host: RemoteReceiver → RemoteRootRenderer → host DOM
end
Note over Worker: Opaque origin ⇒ browser denies localStorage,<br/>cookies, IndexedDB, BroadcastChannel
```
|
||
|
|
9c9c34fccf |
Remove twenty-ui-deprecated and migrate frontend to twenty-ui (#21596)
Migrates `twenty-front`, `twenty-sdk`, and `twenty-front-component-renderer` from `twenty-ui-deprecated` to `twenty-ui` (mechanical import swap — the packages have API parity) and deletes the deprecated package along with its workspace/CI/config wiring. Also adds `@linaria/react`/`@linaria/core` as direct deps of `twenty-front` (it used them transitively via the deprecated package). Note: move the required status check from `ci-ui-status-check` to `ci-new-ui-status-check`. Argos: the Storybook box-model/button-reset baseline shift (the bulk of the visual diffs) is isolated in #21665 — Storybook now loads twenty-ui's global `reset.scss`, which the production app already ships. Once #21665 merges and this branch is rebased, the remaining Argos diffs are component-level visual-parity items only. |
||
|
|
c596a5e342 |
Rename twenty-ui to twenty-ui-deprecated and twenty-new-ui to twenty-ui to prepare package release (#21315)
## Description Promotes the next-gen UI library (formerly `twenty-new-ui`) to the name **`twenty-ui`** (v0.1.0, publishable) and renames the old package to **`twenty-ui-deprecated`**. Rewrites ~1,730 `twenty-ui` imports → `twenty-ui-deprecated`, updates all configs/CI/Docker/deps, and migrates twenty-front's `Toggle` to the new package (first consumer) as a drop-in. ## Next steps - Wire the `ui/v*` publish dispatch (`cd-deploy-tag.yaml` + `.yarnrc.yml`), then tag `ui/v0.1.0` to publish. - Continue migrating components from `twenty-ui-deprecated` → `twenty-ui`. |
||
|
|
4d520a312f |
Allow functional iframes in front components while blocking sandbox escapes (#21145)
Fixes https://github.com/twentyhq/twenty/issues/19899 Front component iframes were previously forced to `sandbox=""`, which fully locks them down: no scripts, no forms, no popups. That broke any legitimate embedded content (maps, widgets, embeds) developers tried to render. But we can't just trust the app-provided sandbox value either: tokens like allow-same-origin or allow-top-navigation would let a malicious embed escape the sandbox and hijack the host Twenty tab. - Add `sanitizeIframeSandbox`, which keeps the iframe useful while enforcing security: applies a safe default (allow-scripts allow-forms allow-popups) when no sandbox is set always forces allow-scripts so embeds work - strips dangerous tokens (`allow-same-origin`, all `allow-top-navigation`*, `allow-popups-to-escape-sandbox`), case-insensitively - Wire it into `createHtmlHostWrapper` so every `iframe` rendered by a front component is sanitized. - Add unit tests for the sanitizer and Storybook interaction tests asserting dangerous sandboxes are stripped. |
||
|
|
e3c79c803c |
Fix standard React form event targets in front components (#20525)
Fixes #20354 ## Problem Front component form events currently expose serialized form state through a sandbox-specific event shape, such as `event.detail.value` and `event.detail.checked`. That works for examples that explicitly read `event.detail`, but it is surprising for app authors writing standard React form handlers: ```tsx onChange={(event) => { setValue(event.target.value); }} Internal app code already has to defend against multiple possible shapes: // Values may live on e.detail.value, e.value, or e.target.value. This suggests the sandbox event shape is leaking into userland. Solution This change keeps the existing event.detail behavior, but also syncs serialized event target properties back onto the remote element before dispatching the event. That means both styles work: // Existing sandbox-specific style event.detail.value; // Standard React style event.target.value; The same applies to checked, files, scroll/media target properties, and similar serialized target state. What Changed Added a shared helper to apply serialized event target properties onto the remote element. Updated generated remote element event configs to dispatch serialized events through a custom event config. Updated the remote-dom element generator so regenerated files preserve this behavior. Updated Storybook form-event examples to use standard React event target reads. Added/updated Storybook coverage for input, checkbox, textarea, select, submit, and caret preservation flows. Validation Ran git diff --check Ran a targeted TypeScript error scan for the changed front component renderer files Manually verified the Storybook FrontComponent/EventForwarding form event story locally: text input updates state checkbox updates state submit reflects the updated JSON Note: local Storybook verification on Windows required temporary local build/cache fixes that are not included in this PR, to keep this change focused on front component event behavior. --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
37908114fc |
[SDK] Extract twenty-front-component-renderer outside of twenty-sdk ( 2.8MB ) (#19021)
Followup https://github.com/twentyhq/twenty/pull/19010 ## Dependency diagram ``` ┌─────────────────────┐ │ twenty-front │ │ (React frontend) │ └─────────┬───────────┘ │ imports runtime: │ FrontComponentRenderer │ FrontComponentRendererWithSdkClient │ useFrontComponentExecutionContext ▼ ┌──────────────────────────────────┐ ┌─────────────────────────┐ │ twenty-front-component-renderer │────────▶│ twenty-sdk │ │ (remote-dom host + worker) │ │ (app developer SDK) │ │ │ │ │ │ imports from twenty-sdk: │ │ Public API: │ │ • types only: │ │ defineFrontComponent │ │ FrontComponentExecutionContext│ │ navigate, closeSide… │ │ NavigateFunction │ │ useFrontComponent… │ │ CloseSidePanelFunction │ │ Command components │ │ CommandConfirmation… │ │ conditional avail. │ │ OpenCommandConfirmation… │ │ │ │ EnqueueSnackbarFunction │ │ Internal only: │ │ etc. │ │ frontComponentHost… │ │ │ │ front-component-build │ │ owns locally: │ │ esbuild plugins │ │ • ALLOWED_HTML_ELEMENTS │ │ │ │ • EVENT_TO_REACT │ └────────────┬────────────┘ │ • HTML_TAG_TO_CUSTOM_ELEMENT… │ │ │ • SerializedEventData │ │ types │ • PropertySchema │ ▼ │ • frontComponentHostComm… │ ┌─────────────────────────┐ │ (local ref to globalThis) │ │ twenty-shared │ │ • setFrontComponentExecution… │ │ (common types/utils) │ │ (local impl, same keys) │ │ AppPath, SidePanelP… │ │ │ │ EnqueueSnackbarParams │ └──────────────────────────────────┘ │ isDefined, … │ │ └─────────────────────────┘ │ also depends on ▼ twenty-shared (types) @remote-dom/* (runtime) @quilted/threads (runtime) react (runtime) ``` **Key points:** - **`twenty-front`** depends on the renderer, **not** on `twenty-sdk` directly (for rendering) - **`twenty-front-component-renderer`** depends on `twenty-sdk` for **types only** (function signatures, `FrontComponentExecutionContext`). The runtime bridge (`frontComponentHostCommunicationApi`) is shared via `globalThis` keys, not module imports - **`twenty-sdk`** has no dependency on the renderer — clean one-way dependency - The renderer owns all remote-dom infrastructure (element schemas, event mappings, custom element tags) that was previously leaking through the SDK's public API - The SDK's `./build` entry point was removed entirely (unused) |