@ttsc/playground 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +7 -0
  2. package/lib/src/compiler/internal/createWorkerCompilerService.d.ts +1 -1
  3. package/lib/src/compiler/internal/createWorkerCompilerService.js +44 -12
  4. package/lib/src/compiler/internal/createWorkerCompilerService.js.map +1 -1
  5. package/lib/src/compiler/loadTypiaSourcePack.d.ts +6 -4
  6. package/lib/src/compiler/loadTypiaSourcePack.js +101 -13
  7. package/lib/src/compiler/loadTypiaSourcePack.js.map +1 -1
  8. package/lib/src/compiler/normalizeError.js +11 -2
  9. package/lib/src/compiler/normalizeError.js.map +1 -1
  10. package/lib/src/index.d.ts +2 -2
  11. package/lib/src/index.js +3 -1
  12. package/lib/src/index.js.map +1 -1
  13. package/lib/src/react/PlaygroundShell.js +188 -106
  14. package/lib/src/react/PlaygroundShell.js.map +1 -1
  15. package/lib/src/react/internal/PlaygroundCompilerLifecycle.d.ts +39 -0
  16. package/lib/src/react/internal/PlaygroundCompilerLifecycle.js +79 -0
  17. package/lib/src/react/internal/PlaygroundCompilerLifecycle.js.map +1 -0
  18. package/lib/src/react/internal/PlaygroundExecutionLifecycle.d.ts +26 -0
  19. package/lib/src/react/internal/PlaygroundExecutionLifecycle.js +49 -0
  20. package/lib/src/react/internal/PlaygroundExecutionLifecycle.js.map +1 -0
  21. package/lib/src/react/internal/recoverTerminalCompilerWorker.d.ts +15 -0
  22. package/lib/src/react/internal/recoverTerminalCompilerWorker.js +35 -0
  23. package/lib/src/react/internal/recoverTerminalCompilerWorker.js.map +1 -0
  24. package/lib/src/sandbox/loadTypiaRuntimePack.d.ts +8 -5
  25. package/lib/src/sandbox/loadTypiaRuntimePack.js +98 -23
  26. package/lib/src/sandbox/loadTypiaRuntimePack.js.map +1 -1
  27. package/lib/src/structures/IInstallTypiaSourcePackOptions.d.ts +5 -1
  28. package/lib/src/structures/ILoadTypiaRuntimePackOptions.d.ts +7 -0
  29. package/lib/src/structures/ILoadTypiaRuntimePackOptions.js +3 -0
  30. package/lib/src/structures/ILoadTypiaRuntimePackOptions.js.map +1 -0
  31. package/lib/src/structures/IPlaygroundShellProps.d.ts +7 -0
  32. package/lib/src/structures/index.d.ts +1 -0
  33. package/lib/src/structures/index.js +1 -0
  34. package/lib/src/structures/index.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/compiler/internal/createWorkerCompilerService.ts +43 -17
  37. package/src/compiler/loadTypiaSourcePack.ts +155 -13
  38. package/src/compiler/normalizeError.ts +15 -2
  39. package/src/index.ts +8 -2
  40. package/src/react/PlaygroundShell.tsx +307 -193
  41. package/src/react/internal/PlaygroundCompilerLifecycle.ts +95 -0
  42. package/src/react/internal/PlaygroundExecutionLifecycle.ts +55 -0
  43. package/src/react/internal/recoverTerminalCompilerWorker.ts +41 -0
  44. package/src/sandbox/loadTypiaRuntimePack.ts +155 -19
  45. package/src/structures/IInstallTypiaSourcePackOptions.ts +5 -1
  46. package/src/structures/ILoadTypiaRuntimePackOptions.ts +7 -0
  47. package/src/structures/IPlaygroundShellProps.ts +7 -0
  48. package/src/structures/index.ts +1 -0
@@ -6,6 +6,7 @@ import {
6
6
  } from "lz-string";
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
 
9
+ import { normalizeError } from "../compiler/normalizeError";
9
10
  import { BUILT_IN_PLAYGROUND_PACKAGES } from "../npm/BUILT_IN_PLAYGROUND_PACKAGES";
10
11
  import { collectExternalPackageNames } from "../npm/collectExternalPackageNames";
11
12
  import { installPlaygroundDependencies } from "../npm/installPlaygroundDependencies";
@@ -25,6 +26,12 @@ import { OptionsPanel } from "./OptionsPanel";
25
26
  import { ResultViewer } from "./ResultViewer";
26
27
  import { SourceEditor } from "./SourceEditor";
27
28
  import { createCompilerClient } from "./createCompilerClient";
29
+ import {
30
+ type IPlaygroundCompilerGeneration,
31
+ PlaygroundCompilerLifecycle,
32
+ } from "./internal/PlaygroundCompilerLifecycle";
33
+ import { PlaygroundExecutionLifecycle } from "./internal/PlaygroundExecutionLifecycle";
34
+ import { recoverTerminalCompilerWorker } from "./internal/recoverTerminalCompilerWorker";
28
35
 
29
36
  const DEFAULT_OPTIONS: ITransformOptions = {
30
37
  typia: true,
@@ -85,8 +92,9 @@ export function PlaygroundShell({
85
92
  const debounce = useRef<number | null>(null);
86
93
  const shareToastTimer = useRef<number | null>(null);
87
94
  const dependencyProgressTimer = useRef<number | null>(null);
88
- const dependencyInstallChain = useRef<Promise<void>>(Promise.resolve());
89
95
  const dependencyAbort = useRef<AbortController | null>(null);
96
+ const compilerLifecycle = useRef(new PlaygroundCompilerLifecycle());
97
+ const executionLifecycle = useRef(new PlaygroundExecutionLifecycle());
90
98
  // Exact mounted identities and active requests. A name-only set cannot
91
99
  // validate a later transitive range or distinguish an npm alias target.
92
100
  const installedDependencies = useRef<
@@ -103,43 +111,105 @@ export function PlaygroundShell({
103
111
  const runtimeDependencyFiles = useRef<Record<string, string>>({});
104
112
  const sourceVersion = useRef(0);
105
113
  const latestSource = useRef(source);
106
- // Race guards: each pipeline (compile/run vs Execute) owns its own epoch.
107
- // Sharing one epoch would make a fresh Execute click invalidate an in-
108
- // flight compile (and vice versa) and leave the spinner/button flags
109
- // stuck because the older pipeline's finally would see a stale epoch.
110
- // `updateSource` bumps the COMPILE epoch only — typing aborts a stale
111
- // compile but does not interrupt a click-driven Execute.
114
+ // Compile/run uses an epoch, while Execute uses an abortable lifecycle.
115
+ // The boundaries stay separate so starting Execute does not invalidate an
116
+ // in-flight compile. Source and option changes explicitly invalidate both
117
+ // pipelines because either makes an older result obsolete.
112
118
  const compileEpoch = useRef(0);
113
- const executeEpoch = useRef(0);
114
- // Retry epoch: each Retry click bumps it; only the latest retry's
115
- // async block is allowed to write boot state. Prior retries' bodies
116
- // bail on epoch mismatch so a fast double-Retry can't leave a stale
117
- // `setBootPhase("ready")` chasing a torn-down connection.
118
- const retryEpoch = useRef(0);
119
119
 
120
120
  const mergedExtraLibs = useMemo(
121
121
  () => ({ ...staticEditorLibs, ...editorExtraLibs }),
122
122
  [staticEditorLibs, editorExtraLibs],
123
123
  );
124
124
 
125
+ const clearDependencyGraph = useCallback((publishEditorLibs = true) => {
126
+ installedDependencies.current = new Map();
127
+ dependencyRoots.current = new Set();
128
+ runtimeDependencyFiles.current = {};
129
+ if (publishEditorLibs) setEditorExtraLibs({});
130
+ }, []);
131
+
132
+ const invalidateCompilerGeneration = useCallback(
133
+ (
134
+ reason: string,
135
+ expected?: IPlaygroundCompilerGeneration,
136
+ publishState: boolean = true,
137
+ ): IPlaygroundCompilerGeneration | undefined => {
138
+ const replacement =
139
+ expected === undefined
140
+ ? compilerLifecycle.current.invalidate()
141
+ : compilerLifecycle.current.invalidateIfCurrent(expected);
142
+ if (replacement === undefined) return undefined;
143
+
144
+ compileEpoch.current++;
145
+ executionLifecycle.current.invalidate(reason);
146
+ const abort = dependencyAbort.current;
147
+ dependencyAbort.current = null;
148
+ abort?.abort(createAbortError(reason));
149
+ if (dependencyProgressTimer.current !== null) {
150
+ window.clearTimeout(dependencyProgressTimer.current);
151
+ dependencyProgressTimer.current = null;
152
+ }
153
+ clearDependencyGraph(publishState);
154
+ if (publishState) {
155
+ setDependencyProgress(null);
156
+ setDependencyPackageNames([]);
157
+ setRunning(false);
158
+ setExecuting(false);
159
+ setBundleError(null);
160
+ setBootError(null);
161
+ setBootPhase("booting");
162
+ }
163
+ return replacement;
164
+ },
165
+ [clearDependencyGraph],
166
+ );
167
+
168
+ const recoverTerminalWorker = useCallback(
169
+ (
170
+ error: unknown,
171
+ generation: IPlaygroundCompilerGeneration,
172
+ ): Promise<boolean> => {
173
+ let replacement: IPlaygroundCompilerGeneration | undefined;
174
+ return recoverTerminalCompilerWorker(error, {
175
+ claim: () => {
176
+ replacement = invalidateCompilerGeneration(
177
+ "compiler Worker replacement required",
178
+ generation,
179
+ );
180
+ return replacement !== undefined;
181
+ },
182
+ reset: client.reset,
183
+ fail: (terminalError) => {
184
+ if (replacement?.isCurrent() !== true) return;
185
+ setBootError(terminalError);
186
+ setBootPhase("failed");
187
+ },
188
+ });
189
+ },
190
+ [client, invalidateCompilerGeneration],
191
+ );
192
+
125
193
  const updateSource = useCallback((next: string) => {
126
194
  sourceVersion.current++;
127
195
  latestSource.current = next;
128
- // Bump BOTH epochs: typing must abort an in-flight compile (the
129
- // result would be stale) AND an in-flight Execute (the bundled code
130
- // would not match what's on screen). Without bumping executeEpoch,
131
- // an Execute whose dependency install was aborted by this typing
132
- // would silently treat the abort as success and bundle against an
133
- // incomplete MemFS — emitting JS for packages the user just edited
134
- // away from, or failing in confusing ways.
196
+ // Typing invalidates both the compile result and any click-driven Execute.
135
197
  compileEpoch.current++;
136
- executeEpoch.current++;
198
+ executionLifecycle.current.invalidate("source changed");
137
199
  dependencyAbort.current?.abort(createAbortError("source changed"));
200
+ setExecuting(false);
138
201
  setDependencyProgress(null);
139
202
  setDependencyPackageNames([]);
140
203
  setSource(next);
141
204
  }, []);
142
205
 
206
+ const updateOptions = useCallback((next: ITransformOptions) => {
207
+ compileEpoch.current++;
208
+ executionLifecycle.current.invalidate("compiler options changed");
209
+ setExecuting(false);
210
+ setOptions(next);
211
+ }, []);
212
+
143
213
  // ── Decode source from URL on mount ──
144
214
  useEffect(() => {
145
215
  const params = new URLSearchParams(window.location.search);
@@ -153,16 +223,33 @@ export function PlaygroundShell({
153
223
  }
154
224
  }, [updateSource]);
155
225
 
226
+ // Establish a generation boundary before any boot work starts. Cleanup
227
+ // fences every callback that captured the old client before resetting it.
228
+ useEffect(() => {
229
+ invalidateCompilerGeneration("compiler client changed");
230
+ return () => {
231
+ // Internal recovery may have replaced the generation captured at setup,
232
+ // but every such replacement still belongs to this client.
233
+ invalidateCompilerGeneration(
234
+ "compiler client disposed",
235
+ undefined,
236
+ false,
237
+ );
238
+ void client.reset();
239
+ };
240
+ }, [client, invalidateCompilerGeneration]);
241
+
156
242
  // ── Eagerly boot the worker so first compile is instant ──
157
243
  useEffect(() => {
158
244
  let cancelled = false;
245
+ const generation = compilerLifecycle.current.capture();
159
246
  setBootPhase("booting");
160
247
  createCompilerService().then(
161
248
  () => {
162
- if (!cancelled) setBootPhase("ready");
249
+ if (!cancelled && generation.isCurrent()) setBootPhase("ready");
163
250
  },
164
251
  (err: unknown) => {
165
- if (!cancelled) {
252
+ if (!cancelled && generation.isCurrent()) {
166
253
  setBootError(err);
167
254
  setBootPhase("failed");
168
255
  }
@@ -178,127 +265,165 @@ export function PlaygroundShell({
178
265
  input: string,
179
266
  version: number = sourceVersion.current,
180
267
  ): Promise<unknown | null> => {
181
- const task = dependencyInstallChain.current.then(async () => {
182
- const firstPassPackageNames = collectExternalPackageNames(
183
- input,
184
- preinstalledPackages,
185
- );
186
- if (
187
- dependencyRootDelta(firstPassPackageNames, dependencyRoots.current)
188
- .changed === false
189
- )
190
- return null;
191
-
192
- await wait(DEPENDENCY_INSTALL_QUIET_MS);
193
- if (sourceVersion.current !== version) return null;
268
+ const result = await compilerLifecycle.current.enqueue(
269
+ async (generation): Promise<unknown | null> => {
270
+ const isCurrent = (): boolean =>
271
+ generation.isCurrent() && sourceVersion.current === version;
272
+ const resetWorker = (): Promise<boolean> =>
273
+ compilerLifecycle.current.resetWorkerIfCurrent(
274
+ generation,
275
+ client.reset,
276
+ clearDependencyGraph,
277
+ );
278
+ if (!isCurrent()) return null;
279
+
280
+ const firstPassPackageNames = collectExternalPackageNames(
281
+ input,
282
+ preinstalledPackages,
283
+ );
284
+ if (
285
+ dependencyRootDelta(firstPassPackageNames, dependencyRoots.current)
286
+ .changed === false
287
+ )
288
+ return null;
194
289
 
195
- const packageNames = collectExternalPackageNames(
196
- latestSource.current,
197
- preinstalledPackages,
198
- );
199
- const delta = dependencyRootDelta(
200
- packageNames,
201
- dependencyRoots.current,
202
- );
203
- if (!delta.changed) return null;
204
- const replacing = delta.removed.length !== 0;
205
- const requested = replacing ? packageNames : delta.added;
290
+ await wait(DEPENDENCY_INSTALL_QUIET_MS);
291
+ if (!isCurrent()) return null;
206
292
 
207
- if (dependencyProgressTimer.current !== null) {
208
- window.clearTimeout(dependencyProgressTimer.current);
209
- dependencyProgressTimer.current = null;
210
- }
211
- setDependencyPackageNames(requested);
212
- const abort = new AbortController();
213
- dependencyAbort.current = abort;
214
- let workerMutated = false;
215
- try {
216
- const installed = await installPlaygroundDependencies(requested, {
217
- installedDependencies: replacing
218
- ? []
219
- : installedDependencies.current.values(),
220
- ignoredPackages: preinstalledPackages,
221
- signal: abort.signal,
222
- onProgress: setDependencyProgress,
223
- });
224
- if (sourceVersion.current !== version) return null;
225
-
226
- if (replacing) {
227
- workerMutated = true;
228
- await client.reset();
229
- installedDependencies.current = new Map();
230
- dependencyRoots.current = new Set();
231
- runtimeDependencyFiles.current = {};
232
- setEditorExtraLibs({});
233
- }
234
- if (Object.keys(installed.compilerFiles).length > 0) {
235
- const service = await createCompilerService();
236
- workerMutated = true;
237
- await service.installDependencies({
238
- files: installed.compilerFiles,
239
- packages: installed.packages.map(({ name, version }) => ({
240
- name,
241
- version,
242
- })),
243
- });
244
- }
245
- installedDependencies.current = new Map(
246
- installed.resolvedDependencies.map(
247
- (dependency) => [dependency.name, dependency] as const,
248
- ),
293
+ const packageNames = collectExternalPackageNames(
294
+ latestSource.current,
295
+ preinstalledPackages,
249
296
  );
250
- dependencyRoots.current = new Set(packageNames);
251
- setEditorExtraLibs((previous) =>
252
- replacing
253
- ? installed.editorLibs
254
- : { ...previous, ...installed.editorLibs },
297
+ const delta = dependencyRootDelta(
298
+ packageNames,
299
+ dependencyRoots.current,
255
300
  );
256
- runtimeDependencyFiles.current = replacing
257
- ? installed.runtimeFiles
258
- : {
259
- ...runtimeDependencyFiles.current,
260
- ...installed.runtimeFiles,
261
- };
262
- dependencyProgressTimer.current = window.setTimeout(() => {
263
- setDependencyProgress(null);
264
- setDependencyPackageNames([]);
301
+ if (!delta.changed) return null;
302
+ const replacing = delta.removed.length !== 0;
303
+ const requested = replacing ? packageNames : delta.added;
304
+
305
+ if (dependencyProgressTimer.current !== null) {
306
+ window.clearTimeout(dependencyProgressTimer.current);
265
307
  dependencyProgressTimer.current = null;
266
- }, 350);
267
- return null;
268
- } catch (error) {
269
- if (workerMutated) {
270
- await client.reset();
271
- installedDependencies.current = new Map();
272
- dependencyRoots.current = new Set();
273
- runtimeDependencyFiles.current = {};
274
- setEditorExtraLibs({});
275
308
  }
276
- if (isAbortError(error)) {
277
- setDependencyProgress(null);
278
- setDependencyPackageNames([]);
309
+ if (!isCurrent()) return null;
310
+ setDependencyPackageNames(requested);
311
+ const abort = new AbortController();
312
+ dependencyAbort.current = abort;
313
+ let workerMutated = false;
314
+ try {
315
+ const installed = await installPlaygroundDependencies(requested, {
316
+ installedDependencies: replacing
317
+ ? []
318
+ : installedDependencies.current.values(),
319
+ ignoredPackages: preinstalledPackages,
320
+ signal: abort.signal,
321
+ onProgress: (progress) => {
322
+ if (isCurrent()) setDependencyProgress(progress);
323
+ },
324
+ });
325
+ if (!isCurrent()) return null;
326
+
327
+ if (replacing) {
328
+ workerMutated = true;
329
+ if (!(await resetWorker())) return null;
330
+ if (!isCurrent()) return null;
331
+ }
332
+ if (Object.keys(installed.compilerFiles).length > 0) {
333
+ if (!isCurrent()) return null;
334
+ const service = await createCompilerService();
335
+ if (!isCurrent()) return null;
336
+ workerMutated = true;
337
+ const installedForSource =
338
+ await compilerLifecycle.current.mutateWorkerIfCurrent(
339
+ generation,
340
+ () => sourceVersion.current === version,
341
+ () =>
342
+ service.installDependencies({
343
+ files: installed.compilerFiles,
344
+ packages: installed.packages.map(({ name, version }) => ({
345
+ name,
346
+ version,
347
+ })),
348
+ }),
349
+ client.reset,
350
+ clearDependencyGraph,
351
+ );
352
+ if (!installedForSource) return null;
353
+ }
354
+ installedDependencies.current = new Map(
355
+ installed.resolvedDependencies.map(
356
+ (dependency) => [dependency.name, dependency] as const,
357
+ ),
358
+ );
359
+ dependencyRoots.current = new Set(packageNames);
360
+ setEditorExtraLibs((previous) =>
361
+ replacing
362
+ ? installed.editorLibs
363
+ : { ...previous, ...installed.editorLibs },
364
+ );
365
+ runtimeDependencyFiles.current = replacing
366
+ ? installed.runtimeFiles
367
+ : {
368
+ ...runtimeDependencyFiles.current,
369
+ ...installed.runtimeFiles,
370
+ };
371
+ const progressTimer = window.setTimeout(() => {
372
+ if (
373
+ dependencyProgressTimer.current === progressTimer &&
374
+ generation.isCurrent()
375
+ ) {
376
+ setDependencyProgress(null);
377
+ setDependencyPackageNames([]);
378
+ dependencyProgressTimer.current = null;
379
+ }
380
+ }, 350);
381
+ dependencyProgressTimer.current = progressTimer;
279
382
  return null;
383
+ } catch (error) {
384
+ if (!generation.isCurrent()) return null;
385
+ if (workerMutated) {
386
+ if (!(await resetWorker())) return null;
387
+ }
388
+ if (!isCurrent()) return null;
389
+ if (isAbortError(error)) {
390
+ setDependencyProgress(null);
391
+ setDependencyPackageNames([]);
392
+ return null;
393
+ }
394
+ setDependencyProgress({
395
+ phase: "error",
396
+ packageName: requested[0],
397
+ completed: 0,
398
+ total: requested.length,
399
+ message: describeUnknownError(error),
400
+ });
401
+ const progressTimer = window.setTimeout(() => {
402
+ if (
403
+ dependencyProgressTimer.current === progressTimer &&
404
+ generation.isCurrent()
405
+ ) {
406
+ setDependencyProgress(null);
407
+ setDependencyPackageNames([]);
408
+ dependencyProgressTimer.current = null;
409
+ }
410
+ }, 2400);
411
+ dependencyProgressTimer.current = progressTimer;
412
+ return error;
413
+ } finally {
414
+ if (dependencyAbort.current === abort)
415
+ dependencyAbort.current = null;
280
416
  }
281
- setDependencyProgress({
282
- phase: "error",
283
- packageName: requested[0],
284
- completed: 0,
285
- total: requested.length,
286
- message: describeUnknownError(error),
287
- });
288
- dependencyProgressTimer.current = window.setTimeout(() => {
289
- setDependencyProgress(null);
290
- setDependencyPackageNames([]);
291
- dependencyProgressTimer.current = null;
292
- }, 2400);
293
- return error;
294
- } finally {
295
- if (dependencyAbort.current === abort) dependencyAbort.current = null;
296
- }
297
- });
298
- dependencyInstallChain.current = task.then(() => {});
299
- return task;
417
+ },
418
+ );
419
+ return result ?? null;
300
420
  },
301
- [client, createCompilerService, preinstalledPackages],
421
+ [
422
+ clearDependencyGraph,
423
+ client,
424
+ createCompilerService,
425
+ preinstalledPackages,
426
+ ],
302
427
  );
303
428
 
304
429
  // ── Run compile when source / options change ──
@@ -311,39 +436,48 @@ export function PlaygroundShell({
311
436
  const run = useCallback(
312
437
  async (input: string, opts: ITransformOptions, version: number) => {
313
438
  const epoch = ++compileEpoch.current;
439
+ const generation = compilerLifecycle.current.capture();
314
440
  setRunning(true);
315
441
  try {
316
442
  const dependencyError = await installDependenciesForSource(
317
443
  input,
318
444
  version,
319
445
  );
320
- if (compileEpoch.current !== epoch) return;
446
+ if (compileEpoch.current !== epoch || !generation.isCurrent()) return;
321
447
  if (dependencyError) {
448
+ if (await recoverTerminalWorker(dependencyError, generation)) return;
322
449
  setResult({
323
450
  type: "error",
324
451
  target: "javascript",
325
- value: normalizeClientError(dependencyError),
452
+ value: normalizeError(dependencyError),
326
453
  });
327
454
  // Keep prior lintDiagnostics intact — a dependency-install blip
328
455
  // shouldn't wipe the user's most recent successful lint output.
329
456
  return;
330
457
  }
331
458
  const service = await createCompilerService();
459
+ if (compileEpoch.current !== epoch || !generation.isCurrent()) return;
332
460
  const next = await service.compile({
333
461
  source: input,
334
462
  options: opts,
335
463
  });
336
- if (compileEpoch.current !== epoch) return;
464
+ if (compileEpoch.current !== epoch || !generation.isCurrent()) return;
465
+ if (
466
+ next.type === "error" &&
467
+ (await recoverTerminalWorker(next.value, generation))
468
+ )
469
+ return;
337
470
  setResult(next);
338
471
  if (opts.lint !== false) {
339
472
  const lint = await service.lint({ source: input, options: opts });
340
- if (compileEpoch.current !== epoch) return;
473
+ if (compileEpoch.current !== epoch || !generation.isCurrent()) return;
341
474
  setLintDiagnostics(lint.diagnostics);
342
475
  } else {
343
476
  setLintDiagnostics([]);
344
477
  }
345
478
  } catch (err) {
346
- if (compileEpoch.current !== epoch) return;
479
+ if (compileEpoch.current !== epoch || !generation.isCurrent()) return;
480
+ if (await recoverTerminalWorker(err, generation)) return;
347
481
  // Surface the error in the diagnostics pane via an error result —
348
482
  // a transient compile/lint/install rejection (tgrid timeout,
349
483
  // message-channel disconnect) must NOT tear the playground into
@@ -352,7 +486,7 @@ export function PlaygroundShell({
352
486
  setResult({
353
487
  type: "error",
354
488
  target: "javascript",
355
- value: normalizeClientError(err),
489
+ value: normalizeError(err),
356
490
  });
357
491
  // Leave lintDiagnostics alone — clearing them on a transient
358
492
  // compile blip would wipe the user's last good lint output.
@@ -360,13 +494,16 @@ export function PlaygroundShell({
360
494
  // Only the winning epoch clears the flag. Older pipelines that
361
495
  // returned early on an epoch mismatch must NOT clear running, or
362
496
  // a fresh in-flight compile would show "ready" while it's still
363
- // working. compileEpoch is bumped only by updateSource and by the
364
- // next run() Execute uses its own executeEpoch — so this guard
365
- // does not stick the spinner across pipeline boundaries.
497
+ // working. Execute has its own lifecycle, so it cannot stick this
498
+ // spinner across pipeline boundaries.
366
499
  if (compileEpoch.current === epoch) setRunning(false);
367
500
  }
368
501
  },
369
- [createCompilerService, installDependenciesForSource],
502
+ [
503
+ createCompilerService,
504
+ installDependenciesForSource,
505
+ recoverTerminalWorker,
506
+ ],
370
507
  );
371
508
 
372
509
  useEffect(() => {
@@ -413,28 +550,11 @@ export function PlaygroundShell({
413
550
  if (dependencyProgressTimer.current !== null)
414
551
  window.clearTimeout(dependencyProgressTimer.current);
415
552
  dependencyAbort.current?.abort(createAbortError("playground unmounted"));
553
+ executionLifecycle.current.invalidate("playground unmounted");
416
554
  },
417
555
  [],
418
556
  );
419
557
 
420
- // Tear down the Worker (+ its wasm instance) when the component
421
- // unmounts or workerUrl changes. Without this an SPA navigation away
422
- // from the playground leaks one Worker per mount; a workerUrl swap
423
- // leaks the previous Worker forever.
424
- //
425
- // Reset the dependency graph too. Its exact versions, requests, roots, and
426
- // runtime files describe only the worker generation that is being closed.
427
- //
428
- useEffect(() => {
429
- setEditorExtraLibs({});
430
- return () => {
431
- void client.reset();
432
- installedDependencies.current = new Map();
433
- dependencyRoots.current = new Set();
434
- runtimeDependencyFiles.current = {};
435
- };
436
- }, [client]);
437
-
438
558
  const onPickExample = useCallback(
439
559
  (id: string) => {
440
560
  const example = examples.find((e) => e.id === id);
@@ -453,10 +573,8 @@ export function PlaygroundShell({
453
573
 
454
574
  const onExecute = useCallback(async () => {
455
575
  if (!executeBundle) return;
456
- // Bump the executeEpoch so a second Execute click invalidates the
457
- // in-flight Execute. compileEpoch is unaffected — the user can edit
458
- // the source mid-Execute without the running compile being torn down.
459
- const epoch = ++executeEpoch.current;
576
+ const attempt = executionLifecycle.current.begin();
577
+ const generation = compilerLifecycle.current.capture();
460
578
  setExecuting(true);
461
579
  setBundleError(null);
462
580
  // Clear the previous run's console output up front. Without this the
@@ -467,7 +585,7 @@ export function PlaygroundShell({
467
585
  setConsoleMessages([]);
468
586
  const messages: IConsoleMessage[] = [];
469
587
  const push = (type: IConsoleMessage["type"], args: unknown[]) => {
470
- if (executeEpoch.current !== epoch) return;
588
+ if (!attempt.isCurrent() || !generation.isCurrent()) return;
471
589
  messages.push({ type, value: args });
472
590
  setConsoleMessages([...messages]);
473
591
  };
@@ -484,18 +602,21 @@ export function PlaygroundShell({
484
602
  currentSource,
485
603
  currentVersion,
486
604
  );
487
- if (executeEpoch.current !== epoch) return;
605
+ if (!attempt.isCurrent() || !generation.isCurrent()) return;
488
606
  if (dependencyError) {
607
+ if (await recoverTerminalWorker(dependencyError, generation)) return;
489
608
  push("error", [dependencyError]);
490
609
  return;
491
610
  }
492
611
  const service = await createCompilerService();
612
+ if (!attempt.isCurrent() || !generation.isCurrent()) return;
493
613
  const compiled = await service.bundle({
494
614
  source: currentSource,
495
615
  options,
496
616
  });
497
- if (executeEpoch.current !== epoch) return;
617
+ if (!attempt.isCurrent() || !generation.isCurrent()) return;
498
618
  if (compiled.type === "error") {
619
+ if (await recoverTerminalWorker(compiled.value, generation)) return;
499
620
  const message =
500
621
  typeof compiled.value === "string"
501
622
  ? compiled.value
@@ -519,18 +640,18 @@ export function PlaygroundShell({
519
640
  await executeBundle(code, {
520
641
  console: sandboxConsole,
521
642
  runtimeFiles: runtimeDependencyFiles.current,
643
+ signal: attempt.signal,
522
644
  });
645
+ if (!generation.isCurrent()) return;
523
646
  } catch (error) {
524
647
  push("error", [error]);
525
648
  }
526
649
  } catch (error) {
527
- if (executeEpoch.current !== epoch) return;
650
+ if (!attempt.isCurrent() || !generation.isCurrent()) return;
651
+ if (await recoverTerminalWorker(error, generation)) return;
528
652
  push("error", [error]);
529
653
  } finally {
530
- // Only the winning epoch clears the flag — same rationale as
531
- // compileEpoch above. With a separate executeEpoch this is robust
532
- // against concurrent click + edit interleavings.
533
- if (executeEpoch.current === epoch) setExecuting(false);
654
+ if (attempt.finish()) setExecuting(false);
534
655
  }
535
656
  // `source` is intentionally NOT a dep: the body snapshots
536
657
  // `latestSource.current` (always fresh ref) rather than reading the
@@ -542,6 +663,7 @@ export function PlaygroundShell({
542
663
  executeBundle,
543
664
  installDependenciesForSource,
544
665
  options,
666
+ recoverTerminalWorker,
545
667
  ]);
546
668
 
547
669
  const allDiagnostics = useMemo(() => {
@@ -616,22 +738,21 @@ export function PlaygroundShell({
616
738
  </pre>
617
739
  <button
618
740
  onClick={() => {
619
- // Each click bumps retryEpoch; only the latest retry's body
620
- // writes boot state. A double-click cancels the prior retry's
621
- // pending writes so it can't flip bootPhase to "ready"
622
- // against a connection the new retry has already torn down.
623
- const epoch = ++retryEpoch.current;
741
+ const failedGeneration = compilerLifecycle.current.capture();
742
+ const retryGeneration = invalidateCompilerGeneration(
743
+ "compiler retry requested",
744
+ failedGeneration,
745
+ );
746
+ if (retryGeneration === undefined) return;
624
747
  void (async () => {
625
748
  await client.reset();
626
- if (retryEpoch.current !== epoch) return;
627
- setBootError(null);
628
- setBootPhase("booting");
749
+ if (!retryGeneration.isCurrent()) return;
629
750
  try {
630
751
  await createCompilerService();
631
- if (retryEpoch.current !== epoch) return;
752
+ if (!retryGeneration.isCurrent()) return;
632
753
  setBootPhase("ready");
633
754
  } catch (err) {
634
- if (retryEpoch.current !== epoch) return;
755
+ if (!retryGeneration.isCurrent()) return;
635
756
  setBootError(err);
636
757
  setBootPhase("failed");
637
758
  }
@@ -805,7 +926,7 @@ export function PlaygroundShell({
805
926
  {optionsOpen && (
806
927
  <OptionsPanel
807
928
  options={options}
808
- onChange={setOptions}
929
+ onChange={updateOptions}
809
930
  onClose={() => setOptionsOpen(false)}
810
931
  toggles={optionToggles}
811
932
  />
@@ -832,13 +953,6 @@ function describeUnknownError(error: unknown): string {
832
953
  }
833
954
  }
834
955
 
835
- function normalizeClientError(error: unknown): unknown {
836
- if (error instanceof Error) {
837
- return { name: error.name, message: error.message, stack: error.stack };
838
- }
839
- return { name: "Error", message: describeUnknownError(error) };
840
- }
841
-
842
956
  function wait(ms: number): Promise<void> {
843
957
  return new Promise((resolve) => window.setTimeout(resolve, ms));
844
958
  }