migrate-dapp
Playbook for absorbing a standalone Decentraland dapp into this SPA. Mirrors how whats-on, blog, jump, social, and cast were brought in. Heavy tier only — every dapp ported so far needs Redux + RTK Query.
Overview
The migration is mechanical once decisions are made. It's never "copy code 1:1" — the source dapp ships its own Web3 stack, navbar/footer, and provider tree, and ALL of those get dropped. The user-facing UI (cards, lists, modals) stays.
Decisions to take with the user FIRST (AskUserQuestion)
Don't guess these — they shape the code.
- Path: source uses some basename (
/social,/jump,/blog,/cast). What public path do we mount under in sites? Singular vs plural matters (/community/:idvs/communities/:id). Verify against the production sitemap, not just the source router. If the source has sub-paths/tabs, ask whether they should be URL-driven or internal state. - Auth: the source almost certainly imports
wagmi,magic-sdk,thirdweb,decentraland-connect(or in cast2's case, an anonymous + token‑in‑URL flow). Sites drops Web3 —useAuthIdentity(localStorage SSO) +signedFetchfor signed mutations, OR keep the source's anonymous flow if that's how the backend already works. Confirm with the user. Unauthenticated CTAs that need identity redirect to the global SSO viaredirectToAuth(path, queryParams). - Sign-in callback: reuse the global
/sign-inlightweight page (already exists insrc/App.tsx). Don't port the source's ownSignInRedirect. - i18n locales: source typically ships en/es/zh. Sites needs all 6 (en, es, fr, ja, ko, zh) per rule 9. Ask: hand-translate fr/ja/ko now, copy English placeholders, or block the PR on localization team.
- Navbar item: ask explicitly — don't add
LandingNavbarentries unprompted.
Use AskUserQuestion for these in plan mode. Cite the trade-offs (e.g. dropping Web3 = ~580–780KB saved, mutations require pre-existing identity).
Phase 1: explore (plan mode)
Launch up to 3 Explore agents in parallel:
- One to map the source dapp: routes, RTK Query clients, types, page components, helpers, i18n keys, segment events, auth flow, tests.
- One to refresh the sites patterns:
src/services/cmsClient.ts,src/features/cms/cms.client.ts,src/shells/store.ts,src/shells/DappsShell.tsx,src/components/Layout/Layout.helpers.ts,src/utils/signedFetch.ts,src/hooks/useAuthIdentity.ts,src/hooks/useBlogPageTracking.ts,src/utils/authRedirect.ts,src/features/profile/profile.client.ts,src/features/cms/useInfiniteBlogPosts.ts, env JSONs. - Optional third agent for path resolution if the source has a
basenamequirk.
Then read the actual files (not just agent summaries) for: source *.client.ts, source *.types.ts, source page components, source i18n locales (all of them — you'll port them).
Files to add (heavy tier)
| Path | Purpose |
|---|---|
src/services/<dapp>Client.ts |
RTK Query base — createApi({ reducerPath: '<dapp>Client', baseQuery: customQuery, tagTypes: [...] }). Custom baseQuery scans localStorage for SSO identity (localStorageGetIdentity + signedFetchFactory), no Redux state, no wagmi. Use a lazy getter for getEnv('<DAPP>_API_URL') (rule 16). |
src/features/<domain>/<domain>.client.ts |
<dapp>Client.injectEndpoints(...). Mutations use onQueryStarted for optimistic updates / cache reads (rule 17). Read cache via endpoint.select(args)(state) — never state.<dapp>Client.queries as any (rule 18). |
src/features/<domain>/<domain>.types.ts |
Domain types + enums. Verify shape against real API response — don't infer from source TS types (feedback verify_api_response_shape). |
src/features/<domain>/<domain>.helpers.ts |
Helpers (URL builders, formatters, color seeders). Each lazy-getter wraps getEnv(...) if it can throw. |
src/features/<domain>/index.ts |
Barrel — re-export public RTK Query hooks (rule 7). NO hook re-exports (rule "Hook location"). |
src/hooks/usePaginated<Domain>.ts + .spec.ts |
Wrappers around the paginated query endpoint. Use usePaginatedQuery if multiple paginations exist; otherwise inline. |
src/hooks/useInfiniteScrollSentinel.ts |
Sentinel-ref IntersectionObserver, only if @dcl/hooks useInfiniteScroll doesn't fit (it operates on window scroll; use sentinel for overflow:auto containers). Already exists post-migrate-social-dapps. |
src/pages/<area>/<Page>.tsx + .styled.ts |
Page component. Setea <Helmet> con title async, llama useBlogPageTracking({ name, properties }). Container con paddingTop: 64 mobile / 96 desktop (rule 13). |
src/pages/<area>/<Area>NotFoundPage.tsx |
Catch-all dentro del area path (/<area>/*). |
src/components/<area>/<Component>/... |
Componentes UI con *.styled.ts co-located, object-syntax styled. List-row components wrapped in memo() (rule 11). NO className selectors — every child is its own styled (feedback no_classname_in_styled_components). |
Files to modify
| Path | Cambio |
|---|---|
src/App.tsx |
Add lazy(() => import('./pages/<area>/<Page>')) and place <Route path="/<path>/..." /> inside <Route element={<DappsShell />}>. |
src/shells/store.ts |
Register [<dapp>Client.reducerPath]: <dapp>Client.reducer and concat its middleware. |
src/shells/DappsShell.tsx |
If pages use <Helmet>, wrap children with <HelmetProvider> (already done in master post-social migration — verify it's there). |
src/components/Layout/Layout.helpers.ts |
Extend isPageTrackingExempt with pathname === '/<path>' and pathname.startsWith('/<path>/') (rule 23 — Helmet+async title race). |
src/config/env/{dev,stg,prd}.json |
Add new env keys. Match .zone for dev/stg, .org for prd. Don't put secrets — these ship to client. |
src/intl/{en,es,fr,ja,ko,zh}.json |
Add <namespace>.* block. Use a node script to insert programmatically (avoids string-matching the trailing }). Validate parity + duplicate keys (rule 9). |
src/modules/segment.types.ts |
Append new SegmentEvent enum entries with a clear prefix (<DAPP>_*). |
.github/ISSUE_TEMPLATE/bug_report.yml |
Add the new route to the Page / Area dropdown — the template explicitly says "Keep options in sync with the routes defined in src/App.tsx". Format: <Dapp> — <Page> (/<path>). |
.github/ISSUE_TEMPLATE/feature_request.yml |
Add a matching entry to the Area dropdown. |
Design system — use decentraland-ui2, NOT custom assets/deps (paid in blood, cast2 PR #403)
Every cast2 source dependency below was a wasted install + extra cleanup commit when porting. Hit the design-system primitives FIRST, drop the source assets:
- No
@emotion/reactor@emotion/styleddirect deps. Sites resolves both transitively throughdecentraland-ui2→@mui/material→@emotion/*. Importingkeyframesfrom@emotion/reactworks without a direct entry inpackage.json. NEVERnpm install @emotion/react @emotion/styled—npm uninstallimmediately if you see the source dapp pinning them. - No
classnamesdep. Sites uses styled-components everywhere; conditional styling goes instyled('div', { shouldForwardProp })<{ $variant: ... }>(({ $variant }) => ({ ... })). If the source usesclassnames, refactor to styled props during the port — don't carry the dep. - Replace static
logo.png(or any DCL wordmark/brand image) with<Logo size="huge" />fromdecentraland-ui2. Sizes:'normal' | 'large' | 'huge' | 'massive'. Used byPressPage,DownloadSuccess, etc. - Replace static onboarding/landing backgrounds with
<AnimatedBackground variant="absolute" />fromdecentraland-ui2. Render it as a sibling of your content inside aposition: relative; isolation: isolatecontainer — no PNG fallback. Existing usages:Home/WhatsOn,Home/ComeHangOut,Invite/InviteHero,Support/ChatCTABanner,Report/ReportSuccess. - Replace hardcoded colours with
dclColors.*fromdecentraland-ui2whenever the match is direct:'white'→dclColors.neutral.white,'#1a1a1a'→dclColors.neutral.softBlack1,'#000'→dclColors.neutral.black. The palette also exposesbase.primary*,brand.{ruby,violet,...},neutral.gray0..gray5. Brand-specific gradients (#210A35,#FF2D55-into-#FF6B82) and rgba overlays stay verbatim. - Static avatar fallback PNG (
avatar.png) → deterministic seeded background viagetAvatarBackgroundColor+getDisplayNamefromsrc/utils/avatarColor.ts(ADR-292 NameColorHelper). The castAvatarcomponent already does this — call sites passname,imageUrl?,ethAddress?and the component picks the seeded colour + initial when there's no image. Same pattern as the in-world client, hue stays consistent across surfaces. - Compress any source PNG > 500 KB to WebP before importing.
cwebp -q 70 input.png -o output.webptypically gives 95–98% reduction (background_watcher.png2.5 MB →.webp88 KB on cast2). Update the import extension and delete the PNG.
Gotchas (paid in blood)
decentraland-ui2does NOT exportmuiIconsor a JumpInmodalProps.title/description/buttonLabelAPI (current dep:^3.13.0). Source dapps using these need rewrites:import CheckIcon from '@mui/icons-material/Check', and explorer launching goes through the sharedsrc/hooks/useLaunchExplorer.tshook (launchDesktopApp+ ui2DownloadModalfallback —JumpInButtonandEditProfileButtonboth consume it).- react-router v7 quirk: a child
<Route path="*" />does NOT match the parent's empty trailing path. If/cast(with no children) renders blank but/cast/anythingshows the catch-all, add<Route index element={<NotFoundPage />} />alongside the wildcard inside the parent route block (cast2 PR #403, commit 306600c). - Reuse existing components first. Check
LiveNowCardItem(whats-on event card),EventDetailModal,useEventDetailModalbefore writing your own. Source dapps often render their own card — DRY by mapping the dapp's event shape intoEventEntryand reusing the whats-on card. The social migration ended up doing exactly this. decentraland-ui2is ESM-only and Jest can't transform it. UI specs MUSTjest.mock('decentraland-ui2', ...)ANDjest.mock('./<Component>.styled', ...). Seesrc/components/social/CommunityDetail/MembersList/MembersList.spec.tsxfor the forwardRef-with-prop-filtering pattern. Don't try to make ts-jest transformdecentraland-ui2— that's the wrong layer.import.meta.envinsrc/config/index.tsdoesn't compile under ts-jest. Specs that touchgetEnv()transitively MUSTjest.mock('../../config/env', () => ({ getEnv: () => 'https://...' })).decentraland-crypto-fetchreferences globalRequest, missing in older jsdom. The base-client smoke spec mustjest.mock('decentraland-crypto-fetch', () => ({ signedFetchFactory: () => async () => new Response('{}') }))BEFOREimport { <dapp>Client }.- No top-level throws in shell-reachable files (rule 16). All env getters are lazy (
getXxxUrl()throws on call, not on import). One throw at module top crashes the whole DappsShell chunk. - No
import { store }in endpoint files (rule 17). UseonQueryStartedfor dispatch,endpoint.select()(state)for reads. - Mutations must be immutable (rule 22). When enriching cached responses, build a
Map/spread new objects — never mutatedraft.data.results[i]keys directly outside ofupdateQueryData. ?action=...auto-execute pattern: when the CTA redirects unauthenticated users to SSO, append?action=join|requestToJoin. After auth, the page re-renders with identity AND the action param, runs the mutation once viauseEffect+executedActionRef, then strips the param vianavigate({ search: '' }, { replace: true }). SeeCommunityDetail.tsx.HelmetProvidermust wrap the lazy heavy chunk. The DappsShell already does this post-social migration. New heavy pages don't need their own provider.- Source dapps use
getRandomRarityColor(theme)(Math.random per render) — do NOT port this directly. Make it deterministic by seed (e.g. address) so avatars don't flicker on rerender. SeegetRarityColor(theme, seed)insrc/features/communities/communities.helpers.ts. - Map RTK Query errors to i18n keys, not raw server strings (rule 10). When the dapp surfaces error UX (e.g. "no active stream", "not authorized"), build a small helper that switches on
err.status(number for HTTP,'FETCH_ERROR'/'TIMEOUT_ERROR'/'PARSING_ERROR'for transport) and returns a stable i18n key.console.error(err)for debugging,setError(t(getCastErrorKey(err, ctx)))for the UI. Seesrc/features/cast2/cast2.errors.ts. - Watch out for env-key renames during merge with master. While you're working in a long-lived branch, parallel dapp PRs can land env keys that collide with yours (cast2 had
WORLDS_CONTENT_URL, social/storage already mergedWORLDS_CONTENT_SERVER_URL). After everygit merge origin/master, grep for anygetEnv('FOO')that no longer exists insrc/config/env/*.jsonand rename. The DappsShell preconnect spec is a fast canary — it asserts the actual key.
Verification (in order — no skipping)
npm run formatnpm run lint:fix— re-run if it auto-fixednpm run lint:pkgnpm run build— catches stricter TS thantsc --noEmitand surfaces missing decentraland-ui2 exportsnpm test— must include the smoke test asserting the new reducer is registered (rule 6)npm run preview+ Chrome DevTools MCP for the route. Check: signed-in vs not, public vs private/gated, mobile breakpoint vs desktop, infinite scroll fires fetches, navbar clearance, Helmet title, page tracking fires AFTER title resolves.- i18n parity one-liner before pushing:
for f in en es fr ja ko zh; do node -e "const j=require('./src/intl/$f.json'); if(!j.<namespace>?.<known_subkey>) throw new Error('$f missing'); console.log('$f ok')"; done - Run
pr-review-toolkit:code-reviewerongit diff master...HEAD. Treat P0/P1 as blockers.
Out of scope by default (ask explicitly to add)
- Navbar item / nav menu wiring
- SEO rewrite for the new path (most absorbed dapps are auth-gated → no SEO benefit)
- Vite manualChunks tweaks (current chunks already cover most patterns)
- Deleting the standalone source repo (separate operation, after merge)
- Web3 in-page wallet connect (explicitly dropped)
Reference: prior migrations
whats-on— events flow + adminblog— Contentful + cms-server full-text searchjump— launcher deep-link handler (places + events + worlds)social— communities (feat/migrate-social-dappsbranch — canonical for the standard auth+RTK shape)cast— LiveKit streaming (PR #403 — canonical for: anonymous + token-in-URL auth,<AnimatedBackground>+<Logo>swap,dclColors/WebP cleanup, react-router v7 index-route fix, RTK Query error → i18n mapping)storage— storage-service-site (subgraph ownership lookups, scene/players queries; usesstorageClient+subgraphClientbase APIs)reels— in-game camera screenshots. Layout-less lightweight tier (the only absorbed dapp not underDappsShell) — fullscreen UX is the whole point, so reels routes are placed BEFORE the<Layout />block insrc/App.tsxand use auseSyncExternalStore-style client (no Redux)report— community report flow. Lightweight (no RTK Query, no Redux); helpers + types only undersrc/features/report/