Skip to content

Reduce main bundle size by ~44% gzipped (732 KB → 412 KB)#4229

Merged
evanpelle merged 1 commit into
mainfrom
bundle
Jun 12, 2026
Merged

Reduce main bundle size by ~44% gzipped (732 KB → 412 KB)#4229
evanpelle merged 1 commit into
mainfrom
bundle

Conversation

@evanpelle

Copy link
Copy Markdown
Collaborator

Summary

Cuts the main JS chunk from 2,891 KB (732 KB gzip) to 1,679 KB (412 KB gzip) by fixing two bundling issues and removing/replacing heavy dependencies. Measured with a per-module renderedLength analysis of the rolldown output (its prod sourcemaps are malformed, so sourcemap-based tools misattribute sizes).

Chunk Before After
index-*.js (min) 2,891 KB 1,679 KB
index-*.js (gzip) 732 KB 412 KB

Changes

  • Sim worker moved out of the main bundle (~512 KB). The ?worker&inline payload is now reached through a dynamic import(), so it lands in its own lazy chunk fetched when a game starts. The worker itself still uses Vite's inline Blob mechanism (with its data: URL fallback) — runtime instantiation is byte-for-byte unchanged.
  • Replaced lit-markdown with marked + the already-bundled DOMPurify (~380 KB). lit-markdown transitively pulled sanitize-html, htmlparser2, postcss, and two copies of entities into the client just to render news markdown. New src/client/Markdown.ts matches its image-stripping default.
  • Dropped colorjs.io (~114 KB). It was only used for ΔE2000 distance in ColorAllocator; colord's lab plugin (already imported there) provides the same CIEDE2000 via .delta(). Only relative magnitudes are compared, so allocation behavior is unchanged.
  • msdf-atlas.json (~319 KB) fetched at runtime like the atlas PNG, preloaded in parallel with worker init in ClientGameRunner so game-load latency is unaffected.
  • Tailwind CSS no longer shipped twice (~158 KB). o-modal imported styles.css?inline, duplicating the emitted stylesheet as a JS string. It now adopts a constructed stylesheet built from the document's own CSS (HTTP-cache hit in prod, <style> tags + HMR re-sync in dev) via SharedStyles.ts.
  • Debug GUI lazy-loaded. lil-gui + gl/debug/* now load on first toggle (46 KB lazy chunk) instead of shipping in the main bundle.

Also looked at the import * as d3 in RadialMenu (~84 KB) but left it: rolldown tree-shakes the metapackage well and all but ~2 KB is the genuine dependency closure of the selection/transition/shape/color APIs in use.

Test plan

  • tsc --noEmit clean
  • ESLint clean
  • Full test suite passes (1,374 + 65 tests)
  • npm run build-prod succeeds; worker/debug chunks present in asset-manifest.json for the R2 upload
  • Manual smoke test in dev: start a game (worker dev path), open a modal (shared stylesheet), open news modal (markdown rendering)

🤖 Generated with Claude Code

The main JS chunk shrinks from 2,891 KB (732 KB gzip) to 1,679 KB
(412 KB gzip) via:

- Load the inlined sim worker through a dynamic import so its ~512 KB
  payload becomes a lazy chunk fetched at game start. The worker itself
  still uses Vite's ?worker&inline Blob mechanism, unchanged at runtime.
- Replace lit-markdown with marked + the already-bundled DOMPurify.
  lit-markdown transitively pulled in sanitize-html, htmlparser2,
  postcss, and entities (~325 KB) to render news markdown.
- Drop colorjs.io (~114 KB); it was only used for deltaE2000 in
  ColorAllocator, which colord's lab plugin already provides.
- Fetch msdf-atlas.json (~319 KB) at runtime like the atlas PNG,
  preloaded in parallel with worker init.
- Stop shipping Tailwind CSS twice: o-modal now adopts the document's
  stylesheet instead of importing styles.css?inline (~158 KB).
- Lazy-load the render-settings debug GUI (lil-gui, ~46 KB chunk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@evanpelle evanpelle requested a review from a team as a code owner June 12, 2026 01:54
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednpm/​marked@​4.3.0 ⏵ 18.0.5100 +110010098 +280

View full report

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR refactors markdown rendering from lit-markdown to marked + DOMPurify, introduces shared CSS infrastructure for Shadow DOM, enables async font atlas preloading overlapped with worker initialization, makes worker creation asynchronous, and simplifies color distance computation using colord instead of colorjs.io.

Changes

Markdown Rendering Migration

Layer / File(s) Summary
New renderMarkdown helper
src/client/Markdown.ts
Adds marked + DOMPurify markdown conversion, sanitizes HTML while forbidding <img> tags unless options.includeImages is true, returns Lit unsafeHTML.
Migrate consumers to renderMarkdown
src/client/NewsModal.ts, src/client/components/NewsBox.ts
Updates NewsModal and NewsBox imports and calls to use renderMarkdown instead of lit-markdown's resolveMarkdown.
Update dependencies
package.json
Adds marked@^18.0.5 to devDependencies replacing lit-markdown@^1.3.2, removes colorjs.io@^0.6.1 from dependencies.

Shadow DOM CSS Architecture

Layer / File(s) Summary
Shared stylesheet infrastructure
src/client/components/baseComponents/SharedStyles.ts
Creates documentStylesSheet() that gathers text from document <style> tags and fetched stylesheets, lazy-initializes a CSSStyleSheet, supports Vite HMR to keep cached sheet in sync with hot-replaced CSS.
Modal component integration
src/client/components/baseComponents/Modal.ts
Updates OModal to use documentStylesSheet() instead of unsafeCSS with inline Tailwind stylesheet.

Async Asset Loading and Lazy GUI

Layer / File(s) Summary
Font atlas runtime preloading
src/client/render/gl/passes/name-pass/AtlasData.ts, src/client/render/gl/index.ts
Replaces static JSON import with runtime fetch, adds preloadAtlasData() to fetch msdf-atlas.json, updates parseAtlasData() to validate preloading is complete before reading atlas data, exports preloadAtlasData from render module.
Lazy debug GUI and startup coordination
src/client/ClientGameRunner.ts
Changes debug GUI from eager creation to lazy loading on first toggle with debugGuiLoading guard, starts atlas preload before worker initialization and awaits it after to overlap fetch/parse with worker setup.

Async Worker Initialization

Layer / File(s) Summary
Async worker creation
src/core/worker/WorkerClient.ts
Adds createGameWorker() async helper using dynamic import, changes worker field to Worker | null, updates initialize() to await worker creation and register message listener on created worker.
Update all postMessage calls
src/core/worker/WorkerClient.ts
Updates eight postMessage sites and cleanup to use this.worker! non-null assertion or optional chaining for the nullable worker field.

Color Distance Computation Refactor

Layer / File(s) Summary
Migrate ColorAllocator to colord.delta
src/client/theme/ColorAllocator.ts
Replaces colorjs.io CIEDE2000 distance with colord.delta, removes LAB conversion helpers and colorjs.io import, simplifies minDeltaE to reduce over Colord values using colord.delta().

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Suggested reviewers

  • Celant

Poem

📦 Dependencies dance—marked and colord take the stage,
🎨 Shadow DOM styles now flow, SharedStyles set the page,
⚡ Assets preload async, workers wake on demand,
🎯 Color math stays true through delta-E's measured hand.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically summarizes the main change: reducing main bundle size from 732 KB to 412 KB gzipped, which is the core objective of all modifications in the changeset.
Description check ✅ Passed The description thoroughly explains the changes in the PR, detailing how each modification contributes to the bundle size reduction with specific metrics and implementation details for each change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed due to a network error.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/ClientGameRunner.ts (1)

529-545: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unhandled rejection if dynamic import fails.

When import("./render/gl/debug/index") rejects (network error, chunk missing), the promise has no .catch() handler. This creates an unhandled promise rejection. The .finally() resets debugGuiLoading, but the error is swallowed with no user feedback.

Add a .catch() to log the failure:

Proposed fix
         import("./render/gl/debug/index")
           .then(({ createDebugGui }) => {
             debugGui = createDebugGui(view.getSettings());
             debugGui.open();
           })
+          .catch((err) => {
+            console.error("Failed to load debug GUI:", err);
+          })
           .finally(() => {
             debugGuiLoading = false;
           });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 529 - 545, The dynamic import in
the ToggleRenderDebugGuiEvent handler can reject and currently has no .catch(),
causing unhandled promise rejections; update the
import("./render/gl/debug/index") promise chain in the eventBus listener (the
block that checks debugGui === null and uses debugGuiLoading) to add a
.catch(err => { /* log error and optionally notify user */ }) before the
.finally so errors are logged (e.g., console.error or the app logger) and the
user can be notified, while keeping the existing .finally that resets
debugGuiLoading; ensure you reference and preserve debugGui, debugGuiLoading,
and createDebugGui semantics when adding the catch.
🧹 Nitpick comments (2)
src/client/components/baseComponents/SharedStyles.ts (1)

14-35: ⚡ Quick win

Prevent stale stylesheet overwrites from overlapping async refreshes.

populate() is launched without coordination from both initial load and HMR. If two runs overlap, a slower older run can finish last and replace newer CSS with stale content.

Proposed fix
 let sheet: CSSStyleSheet | null = null;
+let populateVersion = 0;

 async function populate(target: CSSStyleSheet): Promise<void> {
+  const version = ++populateVersion;
   const parts: string[] = [];
   for (const style of Array.from(document.querySelectorAll("style"))) {
     parts.push(style.textContent ?? "");
   }
@@
-  await target.replace(parts.join("\n"));
+  const nextCss = parts.join("\n");
+  if (version === populateVersion) {
+    await target.replace(nextCss);
+  }
 }

Also applies to: 48-51

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/components/baseComponents/SharedStyles.ts` around lines 14 - 35,
The populate function can suffer from race conditions where overlapping async
runs overwrite newer CSS with stale results; fix by introducing a run
token/version check: create a module-scoped incrementing runId (or attach a
unique token to the target), capture it at the start of populate, and before
calling target.replace(parts.join("\n")) verify the current runId/token still
matches to abort stale results; apply the same pattern to the similar logic
referenced around lines 48-51 (the other stylesheet refresh path) so only the
last-invoked async run performs the replace.
src/client/theme/ColorAllocator.ts (1)

91-98: Clarify/keep the minDeltaE comment; consider optional cleanup of lchPlugin.

The minDeltaE comment is accurate: colord’s color.delta() (CIEDE2000) returns a value normalized to the 0..1 range, so using relative magnitudes is fine.

lchPlugin is extended in src/client/theme/ColorAllocator.ts; if LCH features aren’t used anywhere, removing that setup can simplify startup/code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/theme/ColorAllocator.ts` around lines 91 - 98, Keep the existing
minDeltaE comment as-is (it correctly states that colord.delta() is CIEDE2000
normalized to 0..1) and ensure the minDeltaE function remains unchanged;
separately, audit the file for any usage of LCH features and if none exist,
remove the lchPlugin import/extension and its registration (look for lchPlugin
and any calls that extend colord plugins or use LCH methods) to simplify startup
– if you remove lchPlugin, also remove related tests/refs and run the build to
confirm no runtime LCH usage remains.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/render/gl/passes/name-pass/AtlasData.ts`:
- Around line 28-40: preloadAtlasData currently assigns a rejecting fetch
promise to atlasDataPromise, which gets cached and prevents retries on transient
failures; update preloadAtlasData to attach a .catch handler to the promise
returned by fetch(assetUrl("atlases/msdf-atlas.json")) that clears
atlasDataPromise (and does not mutate atlasData) before rethrowing the error so
subsequent calls can retry, while keeping the current success path that sets
atlasData from the parsed json; operate on the existing identifiers
preloadAtlasData, atlasDataPromise, and atlasData to implement this behavior.

In `@src/core/worker/WorkerClient.ts`:
- Around line 320-323: The cleanup() method currently terminates the worker but
leaves instance state that lets other methods (which call
this.worker!.postMessage(...)) run against a terminated worker; update cleanup()
(in WorkerClient) to also set this.isInitialized = false and this.worker =
undefined (in addition to clearing messageHandlers and gameUpdateCallback) so
post-terminate guards fail and no further posts are attempted.
- Around line 76-79: initialize() can be re-entered and leak workers; add a
guard flag (e.g., this._initializing) and check existing this.worker to prevent
starting a new worker if one is already initialized or initialization is in
progress, set the flag at start and clear it on success/failure, and if a
dispose/teardown is in progress (e.g., this._disposing or a dispose() method
exists) either await its completion or throw/return early; ensure you attach the
event listener (handleWorkerMessage) only after the guard passes and that any
partially created worker is cleaned up on error so createGameWorker() cannot be
leaked and this.worker isn't overwritten unexpectedly.

---

Outside diff comments:
In `@src/client/ClientGameRunner.ts`:
- Around line 529-545: The dynamic import in the ToggleRenderDebugGuiEvent
handler can reject and currently has no .catch(), causing unhandled promise
rejections; update the import("./render/gl/debug/index") promise chain in the
eventBus listener (the block that checks debugGui === null and uses
debugGuiLoading) to add a .catch(err => { /* log error and optionally notify
user */ }) before the .finally so errors are logged (e.g., console.error or the
app logger) and the user can be notified, while keeping the existing .finally
that resets debugGuiLoading; ensure you reference and preserve debugGui,
debugGuiLoading, and createDebugGui semantics when adding the catch.

---

Nitpick comments:
In `@src/client/components/baseComponents/SharedStyles.ts`:
- Around line 14-35: The populate function can suffer from race conditions where
overlapping async runs overwrite newer CSS with stale results; fix by
introducing a run token/version check: create a module-scoped incrementing runId
(or attach a unique token to the target), capture it at the start of populate,
and before calling target.replace(parts.join("\n")) verify the current
runId/token still matches to abort stale results; apply the same pattern to the
similar logic referenced around lines 48-51 (the other stylesheet refresh path)
so only the last-invoked async run performs the replace.

In `@src/client/theme/ColorAllocator.ts`:
- Around line 91-98: Keep the existing minDeltaE comment as-is (it correctly
states that colord.delta() is CIEDE2000 normalized to 0..1) and ensure the
minDeltaE function remains unchanged; separately, audit the file for any usage
of LCH features and if none exist, remove the lchPlugin import/extension and its
registration (look for lchPlugin and any calls that extend colord plugins or use
LCH methods) to simplify startup – if you remove lchPlugin, also remove related
tests/refs and run the build to confirm no runtime LCH usage remains.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ca397c03-6dfd-4879-a058-1a6151a9a5a1

📥 Commits

Reviewing files that changed from the base of the PR and between 8da2291 and c6ec9d2.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (11)
  • package.json
  • src/client/ClientGameRunner.ts
  • src/client/Markdown.ts
  • src/client/NewsModal.ts
  • src/client/components/NewsBox.ts
  • src/client/components/baseComponents/Modal.ts
  • src/client/components/baseComponents/SharedStyles.ts
  • src/client/render/gl/index.ts
  • src/client/render/gl/passes/name-pass/AtlasData.ts
  • src/client/theme/ColorAllocator.ts
  • src/core/worker/WorkerClient.ts

Comment on lines +28 to +40
export function preloadAtlasData(): Promise<void> {
atlasDataPromise ??= fetch(assetUrl("atlases/msdf-atlas.json"))
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch msdf-atlas.json: ${response.status}`);
}
return response.json();
})
.then((json) => {
atlasData = json as RawMsdfAtlas;
});
return atlasDataPromise;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Failed fetch is cached permanently; no retry possible.

If fetch rejects (network error, DNS failure, etc.), atlasDataPromise keeps the rejected promise. All future calls to preloadAtlasData() return that same rejection — no recovery path exists.

Consider resetting the promise on failure so transient errors can be retried:

Proposed fix
 export function preloadAtlasData(): Promise<void> {
-  atlasDataPromise ??= fetch(assetUrl("atlases/msdf-atlas.json"))
+  if (atlasDataPromise !== null) return atlasDataPromise;
+  atlasDataPromise = fetch(assetUrl("atlases/msdf-atlas.json"))
     .then((response) => {
       if (!response.ok) {
         throw new Error(`Failed to fetch msdf-atlas.json: ${response.status}`);
       }
       return response.json();
     })
     .then((json) => {
       atlasData = json as RawMsdfAtlas;
+    })
+    .catch((err) => {
+      atlasDataPromise = null; // allow retry on transient failure
+      throw err;
     });
   return atlasDataPromise;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/name-pass/AtlasData.ts` around lines 28 - 40,
preloadAtlasData currently assigns a rejecting fetch promise to
atlasDataPromise, which gets cached and prevents retries on transient failures;
update preloadAtlasData to attach a .catch handler to the promise returned by
fetch(assetUrl("atlases/msdf-atlas.json")) that clears atlasDataPromise (and
does not mutate atlasData) before rethrowing the error so subsequent calls can
retry, while keeping the current success path that sets atlasData from the
parsed json; operate on the existing identifiers preloadAtlasData,
atlasDataPromise, and atlasData to implement this behavior.

Comment on lines +76 to +79
async initialize(): Promise<void> {
const worker = await createGameWorker();
this.worker = worker;
worker.addEventListener("message", this.handleWorkerMessage.bind(this));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard initialize() against re-entry and dispose races.

At Line 76-79, each call creates a new worker with no guard. If initialize() is called twice (or overlaps with teardown), one worker can be leaked and state can be overwritten unexpectedly.

Suggested patch sketch
 export class WorkerClient {
   private worker: Worker | null = null;
+  private initPromise: Promise<void> | null = null;
+  private disposed = false;
@@
   async initialize(): Promise<void> {
+    if (this.disposed) throw new Error("WorkerClient already disposed");
+    if (this.isInitialized) return;
+    if (this.initPromise) return this.initPromise;
+
-    const worker = await createGameWorker();
-    this.worker = worker;
-    worker.addEventListener("message", this.workerMessageListener);
-
-    return new Promise((resolve, reject) => {
+    this.initPromise = (async () => {
+      const worker = await createGameWorker();
+      if (this.disposed) {
+        worker.terminate();
+        throw new Error("WorkerClient disposed during initialization");
+      }
+      this.worker = worker;
+      worker.addEventListener("message", this.workerMessageListener);
+      await new Promise<void>((resolve, reject) => {
         // existing init handshake...
-    });
+      });
+    })();
+    try {
+      await this.initPromise;
+    } finally {
+      this.initPromise = null;
+    }
   }
@@
   cleanup() {
+    this.disposed = true;
     // ...
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/worker/WorkerClient.ts` around lines 76 - 79, initialize() can be
re-entered and leak workers; add a guard flag (e.g., this._initializing) and
check existing this.worker to prevent starting a new worker if one is already
initialized or initialization is in progress, set the flag at start and clear it
on success/failure, and if a dispose/teardown is in progress (e.g.,
this._disposing or a dispose() method exists) either await its completion or
throw/return early; ensure you attach the event listener (handleWorkerMessage)
only after the guard passes and that any partially created worker is cleaned up
on error so createGameWorker() cannot be leaked and this.worker isn't
overwritten unexpectedly.

Comment on lines 320 to 323
cleanup() {
this.worker.terminate();
this.worker?.terminate();
this.messageHandlers.clear();
this.gameUpdateCallback = undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset worker state in cleanup() to avoid post-terminate runtime failures.

At Line 320-323, cleanup() terminates the worker, but it does not clear isInitialized or worker. After cleanup, methods like Line 115+ still pass the init guard and call this.worker!.postMessage(...), which can target a terminated worker.

Suggested patch
 export class WorkerClient {
   private worker: Worker | null = null;
+  private workerMessageListener = this.handleWorkerMessage.bind(this);
   private isInitialized = false;
@@
   async initialize(): Promise<void> {
     const worker = await createGameWorker();
     this.worker = worker;
-    worker.addEventListener("message", this.handleWorkerMessage.bind(this));
+    worker.addEventListener("message", this.workerMessageListener);
@@
   cleanup() {
-    this.worker?.terminate();
+    if (this.worker) {
+      this.worker.removeEventListener("message", this.workerMessageListener);
+      this.worker.terminate();
+      this.worker = null;
+    }
+    this.isInitialized = false;
     this.messageHandlers.clear();
     this.gameUpdateCallback = undefined;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/worker/WorkerClient.ts` around lines 320 - 323, The cleanup() method
currently terminates the worker but leaves instance state that lets other
methods (which call this.worker!.postMessage(...)) run against a terminated
worker; update cleanup() (in WorkerClient) to also set this.isInitialized =
false and this.worker = undefined (in addition to clearing messageHandlers and
gameUpdateCallback) so post-terminate guards fail and no further posts are
attempted.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Jun 12, 2026
@evanpelle evanpelle merged commit 94f2293 into main Jun 12, 2026
14 of 16 checks passed
@evanpelle evanpelle deleted the bundle branch June 12, 2026 03:07
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Jun 12, 2026
evanpelle added a commit that referenced this pull request Jun 12, 2026
…4242)

## Problem

Since #4229, modals render unstyled in `npm run dev` (no black backdrop,
no Tailwind styling). Production/staging is unaffected.

`documentStylesSheet()` reads the document's `<style>` tags once, at
module-eval time. In dev, Vite injects the Tailwind styles *during*
module evaluation — after that read — so the shared constructed
stylesheet ended up with 7 CSS rules instead of the full Tailwind sheet.
In production the styles come from a `<link rel=stylesheet>` that is
fetched by URL, so timing doesn't matter there.

## Fix

If the document hasn't finished loading when the sheet is first created,
re-populate it once on the window `load` event (which fires after the
entry module graph — and therefore all style injection — completes).
Constructed stylesheets are live, so already-rendered components pick
the styles up without re-rendering. The existing HMR re-populate hook is
unchanged.

## Test plan

- [x] Reproduced in dev with headless Chromium: shared sheet had 7
rules, modal unstyled
- [x] After fix: sheet has full Tailwind rules, solo modal renders with
correct dark styling (screenshot-verified)
- [x] `npx tsc --noEmit`, ESLint clean
- [x] Client test suite: 458 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant