feat: optimized assets preview option (local asset bundles via abgen) - #1396
Conversation
Adds an "Optimized Assets (Asset Bundles)" checkbox to the Preview options. When enabled, pressing Preview spawns a local abgen server (ab-cdn-compatible JIT asset-bundle converter) pointed at the scene's preview server, and re-fires the explorer deeplink with local-ab=true and optimized-assets-url so the client loads production-grade asset bundles instead of raw GLTFs. - abgen binary is expected at <userData>/abgen/abgen (next to its template/ and shader/ assets); if missing or not ready in 10s, preview degrades to the regular raw-GLTF flow with a warning log - one abgen instance per project preview, killed with the preview and on app quit; stdout/stderr streamed to the app log with an [ABGen] prefix - the preview server port is pre-picked (--port) so abgen can point at it before sdk-commands boots; sdk-commands itself is untouched - toggle hidden on Linux (abgen ships windows/mac bundles only) Requires an explorer build with local-ab support (decentraland/unity-explorer#9459). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…les) sdk-commands now owns the abgen sidecar (decentraland/js-sdk-toolchain#1498): it resolves/downloads the binary, boots it against its own preview server, and injects local-ab + optimized-assets-url into the deeplink it fires. The hub's job shrinks to mapping the "Optimized Assets" toggle to the opt-in --asset-bundles flag (feature-detected in the scene's installed sdk-commands, skipped with a warning when unsupported). Deleted: the hub-owned sidecar (abgen.ts), the <userData>/abgen binary location, the preview-port pre-picking, and the deeplink re-fire after capture. Option changes on a running preview now only flip the local-ab param (and strip the url when toggled off). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test this pull request on windows-latestDownload the correct version for your architecture: |
Test this pull request on macos-latestDownload the correct version for your architecture:Click here if you don't know which version to downloadFor running this unsigned version of the app, you will need to run the xattr command on it:
|
Reusing a running preview only rewrote the deeplink, so enabling the toggle mid-session set local-ab without any sidecar: the explorer fell back to the default sidecar port with nothing listening and all asset-bundle traffic — wearables and avatar included — died there. Now a preview spawned without --asset-bundles is restarted when the toggle needs it, and local-ab is only ever set alongside the sidecar url the deeplink actually carries.
…es with it The sidecar lives and dies with the preview process, so toggling off now kills it (via a preview restart) instead of leaving it idling — the client has to relaunch for the mode change anyway, making the restart free from the user's perspective. One rule both ways: the running preview always matches the toggle.
Reviewer feedback: 'Optimize Assets' says what the user gets; asset bundles are an internal detail.
A large scene's first optimized-assets preview holds the deeplink until the sidecar finishes converting (up to minutes), leaving only a bare spinner. The sidecar's progress lines now stream main → renderer, and the Preview button reads 'Optimizing Assets... Ns' while it waits.
When the sidecar reports per-asset progress ([n/total] converting ...) the Preview button shows 'Optimizing Assets... N%'; sidecars without the progress route keep the elapsed-seconds label.
Selecting Optimize Assets now starts converting immediately in the background (spawned with --no-browser, deeplink held), with a live (done/total) counter next to the checkbox. Deselecting cancels the background conversion; previews the user launched are left alone. Pressing Preview mid-conversion waits for the in-flight spawn instead of racing a second one, then fires the held deeplink. The Preview button goes back to its plain label — draft UX, to be refined.
The persisted value made the checkbox flip itself on after settings rehydrated, and with the toggle now starting conversions the option must be a deliberate per-session choice. The stored value is ignored at config load; everything else in previewOptions keeps persisting.
Matchers test the raw stream while handlers receive sanitized text; the dim-reset between '[n/total]' and 'converting' made every progress line fail the test, so conversions ran with no UI feedback.
Sends a progress event the moment the warmup begins — the first real progress line is many seconds away (bundling + sidecar boot) and a silent click read as a broken toggle. The label shows 'preparing...' until per-asset counts arrive, or the elapsed seconds against sidecars without the progress route.
Pressing Preview mid-conversion left an anonymous spinner for up to minutes; the button now reads '62%' — short enough to never truncate — until the held deeplink fires. Drops the unused long-label keys.
sdk-commands no longer self-opens the client for --hub sessions (js-sdk-toolchain c7f7e5c1), which is what the flag was standing in for; the hub firing the captured deeplink is the only launch path.
Feature-detect --asset-bundles in the scene's installed sdk-commands (exposed via a new cli.supportsAssetBundles IPC channel) and only render the Optimize Assets toggle when both the platform and the scene support it. Previously an unsupported scene silently no-op'd the toggle with no feedback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…celled preview Menu: replace the "(preparing…)" text with a spinner and show a right-aligned percentage (instead of n/n) while converting. Preview button: show "Optimizing X%" with an inline cancel (✕). The button and its dropdown are greyed out and inert while optimizing (matching the app's disabled opacity); only the ✕ stays clickable. Cancelling detaches from the launch — the button stops blocking while the conversion keeps running as a background warmup (cli.detachPreview), so the next Preview press is warm. Fix: launchOrKeepWarm no longer fires the deeplink when the preview was cancelled/killed out from under it (no live cache entry) — deselecting Optimize Assets while a launch was pending no longer pops the client open. An explicit Preview press now un-detaches at press time so ✕ reliably keeps the client closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n while optimizing Menu: show a green ready tick on the Optimize Assets row once the conversion finishes. "Ready" is set when progress reaches 100%, or — for an already-cached scene that converts instantly with no progress stream — from the warmup's boolean result (cli.warmupOptimizedAssets now resolves true when the scene ends up optimized, including when a sidecar preview is already running). The tick resets when the toggle is turned off or the scene changes. Preview button: while optimizing, hide the dropdown arrow entirely (instead of greying it) and round the main button's exposed right edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Review: feat: optimized assets preview option (local asset bundles via abgen)
16 files changed (+585 −48) · CI: lint ✅ typecheck ✅ unit ✅ · E2E ❌ (macOS install step — environment issue, unrelated to this PR)
Summary
Well-structured feature that adds an opt-in "Optimize Assets" toggle to preview options, enabling local asset-bundle conversion via the sdk-commands sidecar. The warmup/cancel/detach lifecycle is complex but justified — each piece maps to a real user interaction (convert-on-toggle, cancel-on-untoggle, detach-on-✕). The inflightStarts serialization correctly prevents concurrent spawns for the same path, and the fail-open design degrades gracefully to raw GLTFs at every failure point.
Key design strengths:
- Session-only option — forced off at config load, so a multi-minute conversion is never a startup surprise
- Serialization via
inflightStarts— pressing Preview mid-conversion awaits the in-flight spawn instead of racing a second one warmupflag semantics — detach sets it, explicit Preview clears it,launchOrKeepWarmchecks it — the ordering is safe in single-threaded JS and well-commented
Findings (all P2 — none blocking)
[P2] supportsAssetBundles() reads the same file from disk 3–4 times per preview launch
cli.ts:306 — Called from warmupOptimizedAssets (line 334), the sidecar-disagree check in start (line 421), the extra-args build (line 441), and the renderer via IPC (PreviewOptions.tsx:46). The file lives in node_modules and won't change during a session. Could be cached per path and invalidated on install(). Not a correctness issue — just redundant I/O.
[P2] CSS pseudo-disable pattern skips aria-disabled
styles.css:89, component.tsx:388–394 — The button is visually disabled via pointer-events: none + opacity: 0.5 and functionally inert (onClick={undefined}), but it isn't flagged as aria-disabled="true" for assistive technology. Consider adding aria-disabled to the ButtonGroup when isOptimizing is true.
[P2] previewDetached state is reducer-internal only
slice.ts:107,129 — previewDetached is set/read exclusively inside the reducer (to gate isPreviewRunning on runScene.fulfilled). It could be a local variable in the reducer logic rather than part of the serializable state shape, making it clearer it's not intended for external consumption.
[P2] No new tests for the lifecycle functions
cli.ts — warmupOptimizedAssets, cancelOptimizedAssetsWarmup, detachPreview, supportsAssetBundles have no unit tests. Consistent with the existing codebase (no cli.ts tests exist), but the lifecycle complexity would benefit from coverage — particularly the inflightStarts serialization and warmup-then-preview handoff.
Security review
No security issues found. The new code follows established codebase patterns:
- Process args are array-based (no shell injection)
- URL params use
URLSearchParams(proper encoding) - Progress regexes are all linear (no ReDoS)
- IPC event channel carries trusted data from the main process
supportsAssetBundlesreturns only a boolean (minimal information exposure)
Consumer impact
The PreviewOptions type change (optimizedAssets: boolean) is internal to the creator-hub monorepo — it is not exported as an npm package. The PreviewOptions types found in @dcl/schemas, marketplace, and ui are unrelated wearable/emote preview types. No downstream breakage.
Git conventions (ADR-6) ✅
- Branch:
feat/optimized-assets-preview— matches<type>/<summary> - Title:
feat: optimized assets preview option (local asset bundles via abgen)— matches<type>: <summary>
Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack
decentraland-bot
left a comment
There was a problem hiding this comment.
Updated review — P0 found after deeper concurrency analysis
My earlier approval missed a critical concurrency issue in the cancel flow. Updating to request changes.
P0 — waitFor promise never settles on process kill → cancel permanently breaks Preview
cli.ts:491-506 — When cancelOptimizedAssetsWarmup kills a warmup process, the process.waitFor() promise inside the spawned IIFE (line 492) never settles. Here's why:
bin.ts:cleanup()(line 115-125) disables all matchers and clears the array when the process exitswaitFor(line 227-233) registers its resolve/reject callbacks asoncematchers- When the process is killed, the exit handler (line 152-178) resolves the main
promise(returned by.wait()) but then callscleanup(), which destroys thewaitFormatchers before they can fire - Result: the
waitForpromise hangs forever
Impact chain:
start()hangs atawait spawned(line 506) →finallyblock never runs →inflightStarts.delete(path)never executes- Any subsequent
start()call finds the stale promise viainflightStarts.get(path)(line 406) →await pendinghangs forever - Preview button is permanently broken for this project until app restart
Reproduction: Toggle Optimize Assets ON, wait for conversion to start, then toggle OFF. Now press Preview — it hangs forever.
Suggested fix: Race waitFor against process.wait() to detect process death:
const resultLogs = await Promise.race([
process.waitFor(dclLauncherURL, /CliError|error:/i),
process.wait().then(() => {
throw new Error('Process exited without producing a deeplink');
}),
]);process.wait() settles via the exit handler's promise.resolve() (bin.ts:175), so the race will detect process death and reject spawned, allowing the catch/finally blocks to clean up inflightStarts.
The P2 findings from my initial review still stand (redundant supportsAssetBundles reads, missing aria-disabled, reducer-internal state, no new tests). Everything else about the architecture, security, and consumer impact is solid.
Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack
… Preview Race waitFor against process.wait() in start(): cancelling a warmup killed the process, whose cleanup() disabled the once matchers before they fired, so waitFor never settled — inflightStarts[path] stuck and every later Preview press awaited it forever. Also flag the CSS-inert ButtonGroup with aria-disabled while optimizing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review after fix commit 7fbd924c
The P0 blocker (waitFor promise never settling on process kill) is fixed correctly:
const resultLogs = await Promise.race([
process.waitFor(dclLauncherURL, /CliError|error:/i),
process.wait().then(() => {
throw new Error('Preview process exited before producing a deeplink');
}),
]);Why this works:
- Normal flow:
waitForresolves first (deeplink produced) →process.wait()stays pending while the server runs.Promise.racepicks the deeplink. ✓ - Cancel flow: process killed →
process.wait()settles via the exit handler (promise.resolve()at bin.ts:175) →.then()throws →spawnedrejects →catch/finallyblocks fire →inflightStartscleaned up. ✓ - No unhandled rejection:
Promise.raceinternally calls.then(resolve, reject)on all branches, so the losing branch's eventual rejection is handled. ✓
The aria-disabled P2 fix is also included in this commit. ✓
Remaining P2 findings (unchanged, not blocking)
supportsAssetBundles()redundant disk reads (could be cached per path)previewDetachedstate is reducer-internal only (could be local to reducer logic)- No new tests for lifecycle functions (consistent with codebase but the complexity warrants it)
CI
Lint ✅ · typecheck ✅ · unit ✅ · E2E pending (macOS install step — pre-existing infra issue)
Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack
ReviewReviewed together with decentraland/js-sdk-toolchain#1498. The division of labor is clean (sdk owns the sidecar, hub owns when the held deeplink fires), fail-open is consistent at every layer, and the comments explaining the why are genuinely good. The P1 — version-skew launch behavior (cross-PR)
(The mirror case — old hub + new sdk — is flagged on the sdk PR.) P2 — no testsThis PR adds the most intricate concurrency state machine in the main process ( P2 — warmup/press double-spawn race
P3 — double deeplink on detach + re-pressPress Preview → ✕ → press Preview again mid-conversion: both Minor
VerdictDesign and UX reasoning are strong. I'd hold merge for: (a) the old-sdk double-launch gate, (b) a first tranche of unit tests for |
Restore the Optimize Assets preview toggle per project via a new settings.optimizedAssetsByPath map. The live previewOptions flag still starts off each launch and is hydrated inert on project open (no conversion until Preview), so the preference comes back for a scene that had it on without carrying across projects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Product call: accept the first-preview conversion block instead of converting in the background on toggle select. The toggle is now inert (just a persisted per-project preference); the conversion runs when Preview is pressed, with all feedback on the Preview button (greyed + progress + ✕ to cancel). The dropdown shows a plain checkbox — no spinner, progress or ready tick. This deletes warmupOptimizedAssets/detachPreview, the warmup/warmupOnly flags and the held-deeplink launch path. The hub no longer fires the deeplink on fresh spawns at all — sdk-commands self-opens the client on every version (self-open restored on the sdk PR), which also removes the old-sdk double-open flagged in review. ✕ now cancels the conversion outright (kills the spawn; the pending start settles quietly) instead of detaching to a background warmup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Main (cli.ts): fresh spawns never fire the deeplink (sdk self-opens — guards the double-launch regression), re-focus still fires it, sidecar mismatch restarts the preview, --asset-bundles gating + immediate progress emit, cancel-while-converting settles quietly with no reinstall-retry, cancel after open is a no-op, concurrent starts ride the in-flight spawn, and process death still reinstall-retries once. Renderer (editor slice): cancel clears loading immediately and keeps isPreviewRunning false through the quiet settle, the cancelled flag resets on the next run, failures clear loading, progress store/clear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Move PreviewProgress + PREVIEW_PROGRESS_EVENT to /shared/types/ipc: the type was hand-copied in main/preload/renderer and preload hardcoded the channel name (it cannot import from main). - Cache supportsAssetBundles by the dist file's mtime: the multi-MB sdk-commands bundle was re-read on every dropdown render and Preview press; a reinstall/bump touches the file and busts the cache. Misses (sdk not installed) are not cached so a later install is picked up. - Make the optimizing ✕ a real IconButton (aria-label, keyboard focusable) — it is the only live control inside the CSS-inert, aria-disabled button group and was previously click-only. - Correct the Linux gate: keyed off userAgent instead of the deprecated navigator.platform, and justified by the real constraint (no Unity desktop client ships for Linux; abgen itself has Linux builds). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review after commits 2031a8e..341b124 (4 commits since last approval)
17 files changed (+585 −48) · CI: lint ✅ typecheck ✅ unit ✅ · E2E pending (macOS install step — pre-existing infra issue)
What changed since last review
Four commits address every P1/P2 raised by the prior review rounds:
| Commit | Addresses |
|---|---|
2031a8e persist optimize-assets toggle per project path |
New feature — per-project preference in optimizedAssetsByPath, hydrated on project open |
101ac1a convert on Preview press only — drop the warmup machinery |
P1 version-skew double-launch, P2 warmup/press double-spawn race, P3 double deeplink on detach+re-press, Minor optimizedAssetsReady reliability |
d529015 test: cover the press-to-convert launch contract and cancel flow |
P2 no tests — 38 main-process tests (fresh spawn, re-focus, sidecar mismatch restart, inflight ride-along, cancel settle, crash-reinstall-retry, mtime cache) + 5 slice tests (progress, cancel state machine) |
341b124 address review nits on the preview progress plumbing |
Minor progress type hand-copying — PREVIEW_PROGRESS_EVENT constant and PreviewProgress type now in shared ipc.ts; navigator.platform replaced with navigator.userAgent |
Prior findings — status
| Finding | Status |
|---|---|
P0 waitFor hangs on process kill (fixed in 7fbd924) |
✅ Still correct — Promise.race([waitFor, wait()]) handles cancel and crash |
| P1 Version-skew double-launch (nicoecheza) | ✅ Resolved — hub no longer fires dclDeepLink after a fresh spawn; sdk-commands self-opens the client on all versions. The deeplink is only fired for re-focus of an already-open preview |
| P2 Warmup/press double-spawn race (nicoecheza) | ✅ Resolved — warmup machinery (warmupOptimizedAssets, launchOrKeepWarm, detachPreview, warmup flag) entirely removed |
| P2 No tests | ✅ Resolved — cli.test.ts (390 lines) + slice.spec.ts (122 lines) |
P2 supportsAssetBundles redundant reads |
✅ Resolved — mtime-based cache in assetBundlesSupportCache Map |
P2 Missing aria-disabled |
✅ Resolved — aria-disabled={isOptimizing || undefined} on ButtonGroup |
P2 previewDetached reducer-internal state |
✅ Resolved — removed with warmup machinery; replaced by simpler previewCancelled |
Minor navigator.platform deprecated |
✅ Uses navigator.userAgent.includes('Linux') |
| Minor Progress type hand-copied | ✅ PREVIEW_PROGRESS_EVENT constant + PreviewProgress type in /shared/types/ipc |
Minor CloseIcon keyboard access |
✅ Uses IconButton with aria-label |
New findings (all P2 — none blocking)
[P2] inflightStarts race window is narrower but still present
cli.ts:475 — inflightStarts.set(path, spawned) is registered after several await points (sceneHasLandscapeTerrain, supportsAssetBundles, getEnv, and the synchronous run() call). A concurrent start() entering during that window would not see the inflight entry and would double-spawn, leaking the first process. In practice, the Redux loadingPreview flag disables the Preview button synchronously on dispatch, so the UI prevents the double-click. The race is only reachable through direct IPC calls (e.g. a mobile-QR start racing a Preview press). Risk is low — noting for completeness.
[P2] optimizedAssetsByPath grows unbounded
settings.ts:24 — The per-project preference map is keyed by filesystem path and entries are never cleaned up. Each entry is tiny (string → boolean), so this won't be a practical problem for a long time, but there's no pruning (e.g. dropping entries for projects that no longer exist). Fine as follow-up work.
Security review
No security issues found. Same assessment as the prior review, plus:
- New IPC handlers (
cli.cancelPreview,cli.supportsAssetBundles) follow the existinghandle/invokepattern with no new attack surface supportsAssetBundlesreads from the project's ownnode_modules— no path traversal risk (the path originates from the trusted project config)sendPreviewProgresssends structured data ({path, progress}) over IPC to the renderer — no injection vectoroptimizedAssetsByPathis keyed by the project path from config, not user-supplied input
Consumer impact
No public API surface changed. All modified types (PreviewOptions, AppSettings, PreviewProgress, Ipc) are internal to the creator-hub monorepo. No downstream breakage.
Git conventions (ADR-6) ✅
- Branch:
feat/optimized-assets-preview— matches<type>/<summary> - Title:
feat: optimized assets preview option (local asset bundles via abgen)— matches<type>: <summary>
Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack
Test @dcl/inspector package
|
Drop per-project Optimize Assets preferences for projects that no longer exist: on config load for paths gone from disk, and on unlistProjects for projects removed from the workspace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review after commit 31a15e8
+174 lines across 4 files · CI: lint ✅ typecheck ✅ unit ✅ · E2E pending (pre-existing macOS infra issue)
What changed
Direct fix for the [P2] unbounded optimizedAssetsByPath finding from the prior review. Two complementary cleanup paths:
-
Startup pruning (
config.ts) — on config load, prune entries whose project path no longer exists on disk (existsSync). Sync I/O is fine here — runs once at startup, not in a hot path. The pruned config is persisted via the existing dirty-check (JSON.stringifycomparison). -
Unlist cleanup (
workspace.ts) — when a project is removed from the workspace (unlistProjects), itsoptimizedAssetsByPathentry is deleted in the same config mutation.delete settings.optimizedAssetsByPath?.[_path]safely no-ops when the map is undefined.
Both paths are tested:
config.test.ts(108 lines) — prune gone entries, keep alive ones, persist pruned configworkspace.spec.ts(+46 lines) — drop preference on unlist, handle missing map gracefully
Prior P2 findings — status
| Finding | Status |
|---|---|
inflightStarts race window |
Unchanged — low risk, mitigated by UI-level button disabling |
optimizedAssetsByPath unbounded growth |
✅ Resolved — pruned at startup + on project unlist |
Security
existsSync on config-sourced paths — no new attack surface. The paths originate from the trusted project config, not user input.
Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni [Dalkia] (<@U03JSUQ5Z7U>) via Slack
nicoecheza
left a comment
There was a problem hiding this comment.
Re-reviewed at 31a15e85. The convert-on-press refactor is a big improvement — dropping the warmup/detach machinery removed the two launch races I flagged along with the state they needed. Confirmed fixed:
- Launch contract restored. The fresh spawn no longer fires
dclDeepLink("the sdk already opened the client…"), and js-sdk-toolchain#1498 reverted its no-self-open change. Fresh spawn = sdk self-opens, re-press = hub fires the adjusted deeplink — exactly the pre-PR behavior, so neither direction of version skew double-launches or opens nothing. This was the blocker; it's gone. - Tests.
cli.test.tsis genuinely good — the fakeChildlets the tests drive the deeplink print, process death and cancel independently, and the cases assert the real contract (no self-fire, cache-for-refocus,--asset-bundlesonly when supported, restart on toggle disagreement, reinstall-retry once, reject when the retry dies).config.test.ts, theunlistProjectsspecs and the slice specs cover the new persistence and cancel state. Conventions followed throughout. - Nits addressed:
PREVIEW_PROGRESS_EVENT/PreviewProgressnow live in/shared/types/ipc,supportsAssetBundlesis mtime-cached, the ✕ is a realIconButtonwith anaria-label, and the Linux gate's comment now explains the actual reason (no Unity desktop client) instead of contradicting the sdk's Linux archives.
One new issue, worth fixing before merge — cancel-then-retry silently does nothing.
cancelPreview.pending clears loadingPreview immediately, so the Preview button is live again while main is still inside await killPreview(path) — which resolves only once the process is confirmed dead: ≥100ms of poll interval, and up to the 5s force-kill timeout in bin.ts. A Preview press landing in that window hits
const pending = inflightStarts.get(path);
if (pending) {
await pending.catch(() => {}); // swallows the rejection
return path; // …and reports success
}The in-flight promise is the spawn being killed, so it rejects, the rider swallows it and returns success without spawning anything. runScene.fulfilled then sets isPreviewRunning = true (this run's pending already cleared previewCancelled), so no client opens, nothing is running, and the UI says otherwise. Recovery is another Preview press. "Cancel, then change my mind" is a natural sequence, and the failure is silent.
The narrow fix is to keep the button blocked until the cancel actually completes — clear loadingPreview on cancelPreview.fulfilled (or add a cancelling flag) rather than on pending. Alternatively have the rider distinguish outcomes (await pending.then(() => true, () => false)) and only report success when the spawn it rode actually produced a deeplink — though a fall-through respawn then needs care, since the original's finally will delete whatever inflightStarts entry it finds. The generic case is the same shape: any rider of a spawn that later crashes currently gets a false success.
Related and worth closing at the same time: inflightStarts.set still happens several awaits after the pending check, so two start() calls in the same tick both spawn and the loser leaks (alive, evicted from previewCache, unreachable by killAllPreviews). The UI gating makes this hard to reach now, but the code comment claims the map serializes "a second Preview press (or a mobile-QR start)" and it doesn't quite — and the new test's await flush() between the two starts sits exactly on that boundary. Registering the promise synchronously at start() entry covers this and the above together.
Two small things, non-blocking:
navigator.userAgent.includes('Linux')is safer than the oldnavigator.platformbut still UA sniffing in the renderer;process.platformthrough preload would be exact.- The
existsSyncprune ingetConfigStoragesilently drops the saved preference for any project on a temporarily unmounted volume (external drive, network share). Harmless but slightly surprising.
…s synchronously Cancel-then-retry silently did nothing: cancelPreview.pending cleared loadingPreview while main was still killing the spawn, so a re-press rode the dying spawn, swallowed its rejection and reported a running preview with nothing running. The button now stays blocked until cancelPreview.fulfilled (guarded so a run started mid-cancel, e.g. a mobile-QR start, keeps its own loading state), and the ✕ disables while the cancel is in flight. inflightStarts is now registered synchronously at start() entry via a thin wrapper around doStart(), so same-tick starts ride instead of double-spawning a process previewCache would orphan. The entry spans the reinstall-retry too (the retry recurses into doStart, not start), and the finally delete is guarded against clobbering a later registration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review after commit fb335ad
Verdict: Approved — both remaining P2 findings from the previous review are now fixed. No new issues.
New commit analysis
fb335ad — fix: block Preview until cancel completes and register inflight starts synchronously
This commit addresses the last two actionable findings:
1. inflightStarts race (previously P2) — ✅ Fixed
Problem: inflightStarts.set(path, spawned) happened deep inside start() after several await calls. Two same-tick calls (e.g. Preview press racing a mobile-QR start) could both pass the inflightStarts.get(path) check before either registered, double-spawning a process previewCache would orphan.
Fix: Split start() into a thin synchronous wrapper + doStart() inner function. The wrapper registers the inflight entry synchronously (no await gap between check and set), and the finally guard uses identity comparison (=== entry) so a stale settle never clobbers a later start's entry. The retry path correctly recurses into doStart() instead of start(), avoiding infinite self-ride.
Verified correct:
- The synchronous
doStart()call returns a Promise that gets registered before any microtask yields - The
finallycleanup is identity-guarded: a laterstart()that registered a new entry won't have its entry deleted by the earlier one settling - Retry recursion into
doStart()bypasses the wrapper, so the outertry/finallyspans the full operation including the reinstall-retry — no leak
2. Cancel-then-retry race (previously P2) — ✅ Fixed
Problem: cancelPreview.pending immediately cleared loadingPreview, unblocking the Preview button while main was still killing the spawn. A re-press would ride the dying spawn via inflightStarts, get its rejection swallowed, and report a "running" preview with nothing actually running.
Fix: cancelPreview.pending now only sets previewCancelled = true (keeps loadingPreview = true). cancelPreview.fulfilled/.rejected clear loadingPreview only if previewCancelled is still true — this guard protects a new runScene dispatched mid-cancel (whose .pending clears previewCancelled) from having its loading state stolen. The ✕ button is disabled={previewCancelled} so double-cancel is prevented in the UI too.
State machine verified complete:
runScene.pending→loadingPreview=true, previewCancelled=false✓runScene.fulfilled→loadingPreview=false, isPreviewRunning=true(guarded by!previewCancelled) ✓runScene.rejected→loadingPreview=false, previewCancelled=false✓cancelPreview.pending→previewCancelled=true(button stays blocked) ✓cancelPreview.fulfilled/rejected→if (previewCancelled) loadingPreview=false✓
Test coverage
- +48 lines in cli.test.ts: same-tick serialization test (no
flush()between starts) and ride-during-reinstall test (second start lands whileinstall()is pending) - +51 lines in slice.spec.ts: cancel now asserts button stays blocked until fulfilled; new test verifies a
runScenedispatched mid-cancel keeps its ownloadingPreview
Both race conditions are exercised with controlled async resolution (deferred promises), making the tests deterministic.
Security
No new IPC surfaces, no new user input handling, no secrets exposure. Process management (kill/spawn) is substantively unchanged — only the registration timing is reordered.
CI
- lint: ✅ pass
- unit tests: ✅ pass
- typecheck: ⏳ pending
- E2E: ⏳ pending (pre-existing macOS infra issue)
Reviewed by Jarvis 🤖 · Requested by Juan Ignacio Molteni (<@U03JSUQ5Z7U>) via Slack
…d-assets-url sdk-commands now injects only local-ab=true into the deeplink it fires and no longer emits optimized-assets-url. Key both detection sites (the re-focus local-ab guard and the restart-on-toggle-mismatch check) on the presence of local-ab, which older sdk-commands versions emitted alongside the url, so detection stays backwards compatible. The toggle-off path still strips a legacy optimized-assets-url when present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-preview # Conflicts: # packages/creator-hub/main/src/modules/cli.ts # packages/creator-hub/preload/tests/modules/workspace.spec.ts # packages/creator-hub/renderer/src/components/EditorPage/component.tsx # packages/creator-hub/renderer/src/modules/store/workspace/slice.ts # packages/creator-hub/shared/types/config.ts # packages/creator-hub/shared/types/settings.ts
…-preview # Conflicts: # packages/creator-hub/renderer/src/components/EditorPage/MenuOptions/PreviewOptions.tsx # packages/creator-hub/renderer/src/components/EditorPage/component.tsx # packages/creator-hub/renderer/src/modules/store/translation/locales/en.json # packages/creator-hub/renderer/src/modules/store/translation/locales/es.json # packages/creator-hub/renderer/src/modules/store/translation/locales/zh.json
The asset-bundle sidecar rides the Unity deep-link (local-ab param); the Bevy web client has no deep-link, so an optimized preview can't apply there. Hide the toggle instead of showing a no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sdk-commands no longer emits optimized-assets-url; local-ab is the only sidecar param, so the legacy stripping is dead code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context and Problem Statement
Scene preview always loads raw GLTFs, so creators never see their scene the way it renders in production — GPU-compressed textures, Unity-native meshes, converted materials. Conversion problems (assets that look fine raw but break after asset-bundle conversion) only surface after publishing, and preview performance doesn't reflect what players get.
Solution
A new Optimize Assets checkbox in the Preview options dropdown. Selecting it is inert — it's a per-project preference (persisted in
settings.optimizedAssetsByPath, restored when the scene is reopened, never carried across projects). The conversion runs when Preview is pressed: the hub spawnssdk-commands startwith the opt-in--asset-bundlesflag, and that's the whole integration — sdk-commands owns the abgen sidecar (decentraland/js-sdk-toolchain#1498). It resolves/downloads the pinned binary, boots it against its own preview server, injectslocal-ab=trueinto the deeplink, and opens the client when the scene is ready. The explorer derives the optimized-assets base from the realm; the explorer side that consumes the param is decentraland/unity-explorer#9459.All feedback lives on the Preview button: while converting it greys out and shows
Optimizing N%(parsed from the sidecar's[n/total] converting <file>lines; older sidecars degrade to an elapsed-seconds label) with an inline ✕ to cancel. The dropdown shows a plain checkbox — no spinner, progress or ready tick next to it.Key changes:
IconButtonwitharia-label).--asset-bundlesis a spawn-time flag, so when a running preview disagrees with the toggle, Preview restarts it instead of reusing it. Sidecar detection keys on the presence oflocal-abin the captured deeplink — backwards compatible with older sdk-commands builds, which emittedlocal-abalongside a now-droppedoptimized-assets-url(the hub still strips that legacy param when the toggle goes off).supportsAssetBundlesfeature-detects the flag in the scene's installed sdk-commands (mtime-cached — the multi-MB dist file is only re-read after a reinstall/bump).preview.progress) and its payload type moved to/shared/types/ipc— single home for main, preload and renderer.Earlier revisions of this PR converted in the background on toggle select, holding the deeplink until Preview (which required sdk-commands not to self-open for
--hubsessions). That model and its sdk-side launch changes are fully reverted — the sdk PR's remaining delta is just the sidecar.How to test
Setup — you want two scenes: one on the branch sdk-commands and one on a released version. Easiest is to check out Genesis Plaza twice; in one copy install the branch build:
npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/feat/abgen-preview/dcl-sdk-commands-7.25.1-30386413173.commit-c9645cd.tgz"Leave the other copy untouched (released
@dcl/sdk-commands).Checkbox visibility — the Optimize Assets checkbox appears in the Preview options dropdown only for the scene with the branch sdk installed (feature-detected from the installed
@dcl/sdk-commands). The untouched copy must not show it. (It's also always hidden on Linux — no Unity client there; ignore that platform for testing.)Full flow on the branch-sdk scene — validate all three paths:
Optimizing N%; when conversion finishes the client opens once. Re-running is fast (conversion cache).Also: the toggle preference should come back when you close and reopen the scene, and should not leak into other projects.
Scenes without the branch sdk — everything works exactly as today: no checkbox, Preview opens a single client, no behavior change of any kind. Since this PR touches the shared preview launch path, running this check on a couple more scenes beyond the untouched Genesis Plaza copy (different sdk versions, big and small scenes) would be beneficial as regression coverage.
Testing
local-ab, legacy-deeplink compatibility, in-flight ride-along, mtime cache) + 5 renderer slice tests (cancel state machine); typecheck + lint clean--asset-bundles-capable sdk-commands → sidecar boots, deeplink carrieslocal-ab=true, scene converts (verified against a real scene + Unity Editor client with #9459)Impact
Opt-in only; default off. The launch path is identical to released behavior for every scene — sdk-commands opens the client itself on all versions, so there is no cross-version skew in either direction. Requires a scene whose
@dcl/sdk-commandsincludes js-sdk-toolchain#1498 for the toggle to appear (older scenes are untouched). First optimized preview of a scene blocks on conversion; wearables/emotes stream prebuilt from the production CDN and are never converted locally.🤖 Generated with Claude Code