Skip to content

Move layout generation into a Web Worker (#7) - #8

Merged
YurMil merged 1 commit into
mainfrom
feat/layout-worker
Jul 17, 2026
Merged

Move layout generation into a Web Worker (#7)#8
YurMil merged 1 commit into
mainfrom
feat/layout-worker

Conversation

@YurMil

@YurMil YurMil commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Closes #7.

Moves tube-point layout generation off the main thread into a dedicated Web Worker, so a layout is computed without competing with rendering.

Changes

  • core/layout-strategies: extracted shared computeLayoutPoints(params) (strategy points + cutoff filter), used by both the worker and the fallback.
  • layout-worker + layout-worker-client: compute points in a worker and transfer them back as an interleaved Float64Array buffer (cheap for large layouts). The client recreates the worker after a crash and falls back to a synchronous compute if a worker can't be created or errors — the app never loses layout.
  • useGeneratorState: tubeCoords is now async state, requested on each deferred params change with latest-wins stale-response handling. The initial layout is seeded synchronously to avoid a first-paint flash.
  • MAX_TUBE_POINTS stays as a render/interaction guard (canvas + RBush are still main-thread); comment updated.

Verification

  • pnpm typecheck ✅ · pnpm test (58, incl. new codec round-trip tests) ✅ · pnpm build
  • In-browser: worker actually instantiated (layout-worker.ts?worker_file request), initial 169 holes seeded, pitch→40 recomputes to 109 asynchronously, click-select/hit-test works against worker-provided points, no error boundary.

🤖 Generated with Claude Code

Move the layout computation off the main thread into a dedicated worker:
- shared computeLayoutPoints() runs the strategy + cutoff filter
- layout-worker + client transfer points as a Float64Array buffer
- useGeneratorState requests layouts asynchronously, ignoring stale responses
  (latest wins), seeds the first layout synchronously to avoid a paint flash,
  and falls back to a synchronous compute if the worker is unavailable/errors

MAX_TUBE_POINTS stays as a render/interaction guard (canvas + RBush remain on
the main thread). Codec is unit-tested. Closes #7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@YurMil
YurMil merged commit e65f1c4 into main Jul 17, 2026
2 checks passed
@YurMil
YurMil deleted the feat/layout-worker branch July 17, 2026 17:01

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request offloads the layout point computation to a Web Worker to keep the UI thread responsive, introducing a worker client with synchronous fallback and binary serialization for points. Feedback highlights a critical race condition in the React state hook when handling large layouts, a potential crash during worker instantiation under strict CSPs, and a potential memory leak if message posting throws synchronously.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +61 to 74
const layoutRequestSeq = useRef(0);

useEffect(() => {
if (layoutTooLarge) {
return [];
setTubeCoords([]);
return;
}
return getLayoutStrategy(deferredParams.tubeLayout)
.calculatePoints(deferredParams)
.filter((point) => !isWithinCutoffZone(point, deferredParams));
const seq = ++layoutRequestSeq.current;
void requestLayout(deferredParams).then((points) => {
if (seq === layoutRequestSeq.current) {
setTubeCoords(points);
}
});
}, [deferredParams, layoutTooLarge]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

There is a critical race condition in the useEffect hook when layoutTooLarge becomes true.\n\nIf a layout request is currently pending and the user quickly changes parameters such that layoutTooLarge becomes true:\n1. The effect runs again, enters the if (layoutTooLarge) block, calls setTubeCoords([]), and returns early.\n2. Crucially, layoutRequestSeq.current is NOT incremented because the early return happens before the increment.\n3. When the pending request eventually resolves, the condition seq === layoutRequestSeq.current will evaluate to true (since layoutRequestSeq.current was never incremented).\n4. The stale points will then be set via setTubeCoords(points), completely overwriting the empty array and bypassing the layoutTooLarge guard. This can freeze the main thread/tab when rendering the massive layout.\n\nUsing the standard React active flag pattern in the effect cleanup solves this race condition elegantly and eliminates the need for the layoutRequestSeq ref entirely.

  useEffect(() => {\n    let active = true;\n    if (layoutTooLarge) {\n      setTubeCoords([]);\n      return;\n    }\n    void requestLayout(deferredParams).then((points) => {\n      if (active) {\n        setTubeCoords(points);\n      }\n    });\n    return () => {\n      active = false;\n    };\n  }, [deferredParams, layoutTooLarge]);

Comment on lines +30 to +56
const getWorker = (): Worker | null => {
if (worker) return worker;
if (typeof Worker === 'undefined') return null;

const instance = new Worker(new URL('./layout-worker.ts', import.meta.url), {type: 'module'});
instance.addEventListener('message', (event: MessageEvent<LayoutWorkerResult>) => {
const message = event.data;
if (!message || typeof message !== 'object') return;
const handler = pending.get(message.requestId);
if (!handler) return;
pending.delete(message.requestId);
if (message.ok) {
handler.resolve(decodePoints(message.buffer));
} else {
handler.reject(new Error(message.message));
}
});
instance.addEventListener('error', (event) => {
const error = event.error instanceof Error ? event.error : new Error(event.message || 'Layout worker crashed.');
if (worker === instance) {
destroyWorker(error);
}
});

worker = instance;
return instance;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Instantiating a new Worker can throw a synchronous exception in environments with strict Content Security Policies (CSP) (e.g., SecurityError: Failed to construct 'Worker') or if the browser doesn't support module workers.\n\nSince getWorker() is called synchronously inside requestLayout before the promise is created, any synchronous throw will propagate up and crash the React render/effect cycle (triggering an Error Boundary).\n\nWrapping the worker instantiation in a try...catch block and returning null on failure ensures that the application safely falls back to the synchronous layout computation without crashing.

const getWorker = (): Worker | null => {\n  if (worker) return worker;\n  if (typeof Worker === 'undefined') return null;\n\n  try {\n    const instance = new Worker(new URL('./layout-worker.ts', import.meta.url), {type: 'module'});\n    instance.addEventListener('message', (event: MessageEvent<LayoutWorkerResult>) => {\n      const message = event.data;\n      if (!message || typeof message !== 'object') return;\n      const handler = pending.get(message.requestId);\n      if (!handler) return;\n      pending.delete(message.requestId);\n      if (message.ok) {\n        handler.resolve(decodePoints(message.buffer));\n      } else {\n        handler.reject(new Error(message.message));\n      }\n    });\n    instance.addEventListener('error', (event) => {\n      const error = event.error instanceof Error ? event.error : new Error(event.message || 'Layout worker crashed.');\n      if (worker === instance) {\n        destroyWorker(error);\n      }\n    });\n\n    worker = instance;\n    return instance;\n  } catch (error) {\n    console.error('Failed to initialize layout worker:', error);\n    return null;\n  }\n};

Comment on lines +69 to +73
return new Promise<Point[]>((resolve, reject) => {
pending.set(requestId, {resolve, reject});
const request: LayoutWorkerRequest = {requestId, params};
w.postMessage(request);
}).catch(() => computeLayoutPoints(params));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If w.postMessage(request) throws an error synchronously (for example, a DataCloneError if non-serializable parameters are introduced in the future, or if the worker is in an invalid state), the promise is rejected, but the requestId is never removed from the pending map. This can lead to a memory leak.\n\nWrapping w.postMessage in a try...catch block inside the promise executor and deleting the pending request on failure prevents this leak.

  return new Promise<Point[]>((resolve, reject) => {\n    pending.set(requestId, {resolve, reject});\n    const request: LayoutWorkerRequest = {requestId, params};\n    try {\n      w.postMessage(request);\n    } catch (error) {\n      pending.delete(requestId);\n      reject(error);\n    }\n  }).catch(() => computeLayoutPoints(params));

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 750404f08a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 64 to +66
if (layoutTooLarge) {
return [];
setTubeCoords([]);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate pending layout requests when over the cap

If a valid layout request is still in flight and the user changes to parameters whose estimate exceeds MAX_TUBE_POINTS, this branch clears the coordinates but leaves layoutRequestSeq unchanged. The old promise still has the current sequence value and can later call setTubeCoords(points), so the preview and hole counts can repopulate with an out-of-date layout while layoutTooLarge is true. Increment the sequence or otherwise cancel/ignore pending requests before returning here.

Useful? React with 👍 / 👎.

if (worker) return worker;
if (typeof Worker === 'undefined') return null;

const instance = new Worker(new URL('./layout-worker.ts', import.meta.url), {type: 'module'});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle worker construction failures before falling back

When Worker exists but this constructor throws, for example because a deployed CSP blocks worker-src or module workers are unavailable, the exception escapes before requestLayout reaches its Promise .catch(). In that environment the documented synchronous fallback is skipped and the React effect fails instead of computing the layout on the main thread. Wrap worker creation in try/catch and return null (or fall back directly) on construction failure.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move layout generation into a Web Worker

1 participant