@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
@@ -5,6 +5,7 @@ exports.PlaygroundShell = PlaygroundShell;
5
5
  const jsx_runtime_1 = require("react/jsx-runtime");
6
6
  const lz_string_1 = require("lz-string");
7
7
  const react_1 = require("react");
8
+ const normalizeError_1 = require("../compiler/normalizeError");
8
9
  const BUILT_IN_PLAYGROUND_PACKAGES_1 = require("../npm/BUILT_IN_PLAYGROUND_PACKAGES");
9
10
  const collectExternalPackageNames_1 = require("../npm/collectExternalPackageNames");
10
11
  const installPlaygroundDependencies_1 = require("../npm/installPlaygroundDependencies");
@@ -18,6 +19,9 @@ const OptionsPanel_1 = require("./OptionsPanel");
18
19
  const ResultViewer_1 = require("./ResultViewer");
19
20
  const SourceEditor_1 = require("./SourceEditor");
20
21
  const createCompilerClient_1 = require("./createCompilerClient");
22
+ const PlaygroundCompilerLifecycle_1 = require("./internal/PlaygroundCompilerLifecycle");
23
+ const PlaygroundExecutionLifecycle_1 = require("./internal/PlaygroundExecutionLifecycle");
24
+ const recoverTerminalCompilerWorker_1 = require("./internal/recoverTerminalCompilerWorker");
21
25
  const DEFAULT_OPTIONS = {
22
26
  typia: true,
23
27
  lint: true,
@@ -48,8 +52,9 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
48
52
  const debounce = (0, react_1.useRef)(null);
49
53
  const shareToastTimer = (0, react_1.useRef)(null);
50
54
  const dependencyProgressTimer = (0, react_1.useRef)(null);
51
- const dependencyInstallChain = (0, react_1.useRef)(Promise.resolve());
52
55
  const dependencyAbort = (0, react_1.useRef)(null);
56
+ const compilerLifecycle = (0, react_1.useRef)(new PlaygroundCompilerLifecycle_1.PlaygroundCompilerLifecycle());
57
+ const executionLifecycle = (0, react_1.useRef)(new PlaygroundExecutionLifecycle_1.PlaygroundExecutionLifecycle());
53
58
  // Exact mounted identities and active requests. A name-only set cannot
54
59
  // validate a later transitive range or distinguish an npm alias target.
55
60
  const installedDependencies = (0, react_1.useRef)(new Map());
@@ -64,37 +69,80 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
64
69
  const runtimeDependencyFiles = (0, react_1.useRef)({});
65
70
  const sourceVersion = (0, react_1.useRef)(0);
66
71
  const latestSource = (0, react_1.useRef)(source);
67
- // Race guards: each pipeline (compile/run vs Execute) owns its own epoch.
68
- // Sharing one epoch would make a fresh Execute click invalidate an in-
69
- // flight compile (and vice versa) and leave the spinner/button flags
70
- // stuck because the older pipeline's finally would see a stale epoch.
71
- // `updateSource` bumps the COMPILE epoch only — typing aborts a stale
72
- // compile but does not interrupt a click-driven Execute.
72
+ // Compile/run uses an epoch, while Execute uses an abortable lifecycle.
73
+ // The boundaries stay separate so starting Execute does not invalidate an
74
+ // in-flight compile. Source and option changes explicitly invalidate both
75
+ // pipelines because either makes an older result obsolete.
73
76
  const compileEpoch = (0, react_1.useRef)(0);
74
- const executeEpoch = (0, react_1.useRef)(0);
75
- // Retry epoch: each Retry click bumps it; only the latest retry's
76
- // async block is allowed to write boot state. Prior retries' bodies
77
- // bail on epoch mismatch so a fast double-Retry can't leave a stale
78
- // `setBootPhase("ready")` chasing a torn-down connection.
79
- const retryEpoch = (0, react_1.useRef)(0);
80
77
  const mergedExtraLibs = (0, react_1.useMemo)(() => ({ ...staticEditorLibs, ...editorExtraLibs }), [staticEditorLibs, editorExtraLibs]);
78
+ const clearDependencyGraph = (0, react_1.useCallback)((publishEditorLibs = true) => {
79
+ installedDependencies.current = new Map();
80
+ dependencyRoots.current = new Set();
81
+ runtimeDependencyFiles.current = {};
82
+ if (publishEditorLibs)
83
+ setEditorExtraLibs({});
84
+ }, []);
85
+ const invalidateCompilerGeneration = (0, react_1.useCallback)((reason, expected, publishState = true) => {
86
+ const replacement = expected === undefined
87
+ ? compilerLifecycle.current.invalidate()
88
+ : compilerLifecycle.current.invalidateIfCurrent(expected);
89
+ if (replacement === undefined)
90
+ return undefined;
91
+ compileEpoch.current++;
92
+ executionLifecycle.current.invalidate(reason);
93
+ const abort = dependencyAbort.current;
94
+ dependencyAbort.current = null;
95
+ abort?.abort(createAbortError(reason));
96
+ if (dependencyProgressTimer.current !== null) {
97
+ window.clearTimeout(dependencyProgressTimer.current);
98
+ dependencyProgressTimer.current = null;
99
+ }
100
+ clearDependencyGraph(publishState);
101
+ if (publishState) {
102
+ setDependencyProgress(null);
103
+ setDependencyPackageNames([]);
104
+ setRunning(false);
105
+ setExecuting(false);
106
+ setBundleError(null);
107
+ setBootError(null);
108
+ setBootPhase("booting");
109
+ }
110
+ return replacement;
111
+ }, [clearDependencyGraph]);
112
+ const recoverTerminalWorker = (0, react_1.useCallback)((error, generation) => {
113
+ let replacement;
114
+ return (0, recoverTerminalCompilerWorker_1.recoverTerminalCompilerWorker)(error, {
115
+ claim: () => {
116
+ replacement = invalidateCompilerGeneration("compiler Worker replacement required", generation);
117
+ return replacement !== undefined;
118
+ },
119
+ reset: client.reset,
120
+ fail: (terminalError) => {
121
+ if (replacement?.isCurrent() !== true)
122
+ return;
123
+ setBootError(terminalError);
124
+ setBootPhase("failed");
125
+ },
126
+ });
127
+ }, [client, invalidateCompilerGeneration]);
81
128
  const updateSource = (0, react_1.useCallback)((next) => {
82
129
  sourceVersion.current++;
83
130
  latestSource.current = next;
84
- // Bump BOTH epochs: typing must abort an in-flight compile (the
85
- // result would be stale) AND an in-flight Execute (the bundled code
86
- // would not match what's on screen). Without bumping executeEpoch,
87
- // an Execute whose dependency install was aborted by this typing
88
- // would silently treat the abort as success and bundle against an
89
- // incomplete MemFS — emitting JS for packages the user just edited
90
- // away from, or failing in confusing ways.
131
+ // Typing invalidates both the compile result and any click-driven Execute.
91
132
  compileEpoch.current++;
92
- executeEpoch.current++;
133
+ executionLifecycle.current.invalidate("source changed");
93
134
  dependencyAbort.current?.abort(createAbortError("source changed"));
135
+ setExecuting(false);
94
136
  setDependencyProgress(null);
95
137
  setDependencyPackageNames([]);
96
138
  setSource(next);
97
139
  }, []);
140
+ const updateOptions = (0, react_1.useCallback)((next) => {
141
+ compileEpoch.current++;
142
+ executionLifecycle.current.invalidate("compiler options changed");
143
+ setExecuting(false);
144
+ setOptions(next);
145
+ }, []);
98
146
  // ── Decode source from URL on mount ──
99
147
  (0, react_1.useEffect)(() => {
100
148
  const params = new URLSearchParams(window.location.search);
@@ -107,15 +155,27 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
107
155
  }
108
156
  }
109
157
  }, [updateSource]);
158
+ // Establish a generation boundary before any boot work starts. Cleanup
159
+ // fences every callback that captured the old client before resetting it.
160
+ (0, react_1.useEffect)(() => {
161
+ invalidateCompilerGeneration("compiler client changed");
162
+ return () => {
163
+ // Internal recovery may have replaced the generation captured at setup,
164
+ // but every such replacement still belongs to this client.
165
+ invalidateCompilerGeneration("compiler client disposed", undefined, false);
166
+ void client.reset();
167
+ };
168
+ }, [client, invalidateCompilerGeneration]);
110
169
  // ── Eagerly boot the worker so first compile is instant ──
111
170
  (0, react_1.useEffect)(() => {
112
171
  let cancelled = false;
172
+ const generation = compilerLifecycle.current.capture();
113
173
  setBootPhase("booting");
114
174
  createCompilerService().then(() => {
115
- if (!cancelled)
175
+ if (!cancelled && generation.isCurrent())
116
176
  setBootPhase("ready");
117
177
  }, (err) => {
118
- if (!cancelled) {
178
+ if (!cancelled && generation.isCurrent()) {
119
179
  setBootError(err);
120
180
  setBootPhase("failed");
121
181
  }
@@ -125,13 +185,17 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
125
185
  };
126
186
  }, [createCompilerService]);
127
187
  const installDependenciesForSource = (0, react_1.useCallback)(async (input, version = sourceVersion.current) => {
128
- const task = dependencyInstallChain.current.then(async () => {
188
+ const result = await compilerLifecycle.current.enqueue(async (generation) => {
189
+ const isCurrent = () => generation.isCurrent() && sourceVersion.current === version;
190
+ const resetWorker = () => compilerLifecycle.current.resetWorkerIfCurrent(generation, client.reset, clearDependencyGraph);
191
+ if (!isCurrent())
192
+ return null;
129
193
  const firstPassPackageNames = (0, collectExternalPackageNames_1.collectExternalPackageNames)(input, preinstalledPackages);
130
194
  if (dependencyRootDelta(firstPassPackageNames, dependencyRoots.current)
131
195
  .changed === false)
132
196
  return null;
133
197
  await wait(DEPENDENCY_INSTALL_QUIET_MS);
134
- if (sourceVersion.current !== version)
198
+ if (!isCurrent())
135
199
  return null;
136
200
  const packageNames = (0, collectExternalPackageNames_1.collectExternalPackageNames)(latestSource.current, preinstalledPackages);
137
201
  const delta = dependencyRootDelta(packageNames, dependencyRoots.current);
@@ -143,6 +207,8 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
143
207
  window.clearTimeout(dependencyProgressTimer.current);
144
208
  dependencyProgressTimer.current = null;
145
209
  }
210
+ if (!isCurrent())
211
+ return null;
146
212
  setDependencyPackageNames(requested);
147
213
  const abort = new AbortController();
148
214
  dependencyAbort.current = abort;
@@ -154,28 +220,36 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
154
220
  : installedDependencies.current.values(),
155
221
  ignoredPackages: preinstalledPackages,
156
222
  signal: abort.signal,
157
- onProgress: setDependencyProgress,
223
+ onProgress: (progress) => {
224
+ if (isCurrent())
225
+ setDependencyProgress(progress);
226
+ },
158
227
  });
159
- if (sourceVersion.current !== version)
228
+ if (!isCurrent())
160
229
  return null;
161
230
  if (replacing) {
162
231
  workerMutated = true;
163
- await client.reset();
164
- installedDependencies.current = new Map();
165
- dependencyRoots.current = new Set();
166
- runtimeDependencyFiles.current = {};
167
- setEditorExtraLibs({});
232
+ if (!(await resetWorker()))
233
+ return null;
234
+ if (!isCurrent())
235
+ return null;
168
236
  }
169
237
  if (Object.keys(installed.compilerFiles).length > 0) {
238
+ if (!isCurrent())
239
+ return null;
170
240
  const service = await createCompilerService();
241
+ if (!isCurrent())
242
+ return null;
171
243
  workerMutated = true;
172
- await service.installDependencies({
244
+ const installedForSource = await compilerLifecycle.current.mutateWorkerIfCurrent(generation, () => sourceVersion.current === version, () => service.installDependencies({
173
245
  files: installed.compilerFiles,
174
246
  packages: installed.packages.map(({ name, version }) => ({
175
247
  name,
176
248
  version,
177
249
  })),
178
- });
250
+ }), client.reset, clearDependencyGraph);
251
+ if (!installedForSource)
252
+ return null;
179
253
  }
180
254
  installedDependencies.current = new Map(installed.resolvedDependencies.map((dependency) => [dependency.name, dependency]));
181
255
  dependencyRoots.current = new Set(packageNames);
@@ -188,21 +262,26 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
188
262
  ...runtimeDependencyFiles.current,
189
263
  ...installed.runtimeFiles,
190
264
  };
191
- dependencyProgressTimer.current = window.setTimeout(() => {
192
- setDependencyProgress(null);
193
- setDependencyPackageNames([]);
194
- dependencyProgressTimer.current = null;
265
+ const progressTimer = window.setTimeout(() => {
266
+ if (dependencyProgressTimer.current === progressTimer &&
267
+ generation.isCurrent()) {
268
+ setDependencyProgress(null);
269
+ setDependencyPackageNames([]);
270
+ dependencyProgressTimer.current = null;
271
+ }
195
272
  }, 350);
273
+ dependencyProgressTimer.current = progressTimer;
196
274
  return null;
197
275
  }
198
276
  catch (error) {
277
+ if (!generation.isCurrent())
278
+ return null;
199
279
  if (workerMutated) {
200
- await client.reset();
201
- installedDependencies.current = new Map();
202
- dependencyRoots.current = new Set();
203
- runtimeDependencyFiles.current = {};
204
- setEditorExtraLibs({});
280
+ if (!(await resetWorker()))
281
+ return null;
205
282
  }
283
+ if (!isCurrent())
284
+ return null;
206
285
  if (isAbortError(error)) {
207
286
  setDependencyProgress(null);
208
287
  setDependencyPackageNames([]);
@@ -215,11 +294,15 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
215
294
  total: requested.length,
216
295
  message: describeUnknownError(error),
217
296
  });
218
- dependencyProgressTimer.current = window.setTimeout(() => {
219
- setDependencyProgress(null);
220
- setDependencyPackageNames([]);
221
- dependencyProgressTimer.current = null;
297
+ const progressTimer = window.setTimeout(() => {
298
+ if (dependencyProgressTimer.current === progressTimer &&
299
+ generation.isCurrent()) {
300
+ setDependencyProgress(null);
301
+ setDependencyPackageNames([]);
302
+ dependencyProgressTimer.current = null;
303
+ }
222
304
  }, 2400);
305
+ dependencyProgressTimer.current = progressTimer;
223
306
  return error;
224
307
  }
225
308
  finally {
@@ -227,9 +310,13 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
227
310
  dependencyAbort.current = null;
228
311
  }
229
312
  });
230
- dependencyInstallChain.current = task.then(() => { });
231
- return task;
232
- }, [client, createCompilerService, preinstalledPackages]);
313
+ return result ?? null;
314
+ }, [
315
+ clearDependencyGraph,
316
+ client,
317
+ createCompilerService,
318
+ preinstalledPackages,
319
+ ]);
233
320
  // ── Run compile when source / options change ──
234
321
  //
235
322
  // `target` (the active tab) is intentionally NOT a trigger here: the
@@ -239,32 +326,40 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
239
326
  // click would burn multiple seconds of work per click.
240
327
  const run = (0, react_1.useCallback)(async (input, opts, version) => {
241
328
  const epoch = ++compileEpoch.current;
329
+ const generation = compilerLifecycle.current.capture();
242
330
  setRunning(true);
243
331
  try {
244
332
  const dependencyError = await installDependenciesForSource(input, version);
245
- if (compileEpoch.current !== epoch)
333
+ if (compileEpoch.current !== epoch || !generation.isCurrent())
246
334
  return;
247
335
  if (dependencyError) {
336
+ if (await recoverTerminalWorker(dependencyError, generation))
337
+ return;
248
338
  setResult({
249
339
  type: "error",
250
340
  target: "javascript",
251
- value: normalizeClientError(dependencyError),
341
+ value: (0, normalizeError_1.normalizeError)(dependencyError),
252
342
  });
253
343
  // Keep prior lintDiagnostics intact — a dependency-install blip
254
344
  // shouldn't wipe the user's most recent successful lint output.
255
345
  return;
256
346
  }
257
347
  const service = await createCompilerService();
348
+ if (compileEpoch.current !== epoch || !generation.isCurrent())
349
+ return;
258
350
  const next = await service.compile({
259
351
  source: input,
260
352
  options: opts,
261
353
  });
262
- if (compileEpoch.current !== epoch)
354
+ if (compileEpoch.current !== epoch || !generation.isCurrent())
355
+ return;
356
+ if (next.type === "error" &&
357
+ (await recoverTerminalWorker(next.value, generation)))
263
358
  return;
264
359
  setResult(next);
265
360
  if (opts.lint !== false) {
266
361
  const lint = await service.lint({ source: input, options: opts });
267
- if (compileEpoch.current !== epoch)
362
+ if (compileEpoch.current !== epoch || !generation.isCurrent())
268
363
  return;
269
364
  setLintDiagnostics(lint.diagnostics);
270
365
  }
@@ -273,7 +368,9 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
273
368
  }
274
369
  }
275
370
  catch (err) {
276
- if (compileEpoch.current !== epoch)
371
+ if (compileEpoch.current !== epoch || !generation.isCurrent())
372
+ return;
373
+ if (await recoverTerminalWorker(err, generation))
277
374
  return;
278
375
  // Surface the error in the diagnostics pane via an error result —
279
376
  // a transient compile/lint/install rejection (tgrid timeout,
@@ -283,7 +380,7 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
283
380
  setResult({
284
381
  type: "error",
285
382
  target: "javascript",
286
- value: normalizeClientError(err),
383
+ value: (0, normalizeError_1.normalizeError)(err),
287
384
  });
288
385
  // Leave lintDiagnostics alone — clearing them on a transient
289
386
  // compile blip would wipe the user's last good lint output.
@@ -292,13 +389,16 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
292
389
  // Only the winning epoch clears the flag. Older pipelines that
293
390
  // returned early on an epoch mismatch must NOT clear running, or
294
391
  // a fresh in-flight compile would show "ready" while it's still
295
- // working. compileEpoch is bumped only by updateSource and by the
296
- // next run() Execute uses its own executeEpoch — so this guard
297
- // does not stick the spinner across pipeline boundaries.
392
+ // working. Execute has its own lifecycle, so it cannot stick this
393
+ // spinner across pipeline boundaries.
298
394
  if (compileEpoch.current === epoch)
299
395
  setRunning(false);
300
396
  }
301
- }, [createCompilerService, installDependenciesForSource]);
397
+ }, [
398
+ createCompilerService,
399
+ installDependenciesForSource,
400
+ recoverTerminalWorker,
401
+ ]);
302
402
  (0, react_1.useEffect)(() => {
303
403
  if (bootPhase !== "ready")
304
404
  return;
@@ -342,24 +442,8 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
342
442
  if (dependencyProgressTimer.current !== null)
343
443
  window.clearTimeout(dependencyProgressTimer.current);
344
444
  dependencyAbort.current?.abort(createAbortError("playground unmounted"));
445
+ executionLifecycle.current.invalidate("playground unmounted");
345
446
  }, []);
346
- // Tear down the Worker (+ its wasm instance) when the component
347
- // unmounts or workerUrl changes. Without this an SPA navigation away
348
- // from the playground leaks one Worker per mount; a workerUrl swap
349
- // leaks the previous Worker forever.
350
- //
351
- // Reset the dependency graph too. Its exact versions, requests, roots, and
352
- // runtime files describe only the worker generation that is being closed.
353
- //
354
- (0, react_1.useEffect)(() => {
355
- setEditorExtraLibs({});
356
- return () => {
357
- void client.reset();
358
- installedDependencies.current = new Map();
359
- dependencyRoots.current = new Set();
360
- runtimeDependencyFiles.current = {};
361
- };
362
- }, [client]);
363
447
  const onPickExample = (0, react_1.useCallback)((id) => {
364
448
  const example = examples.find((e) => e.id === id);
365
449
  if (example) {
@@ -374,10 +458,8 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
374
458
  const onExecute = (0, react_1.useCallback)(async () => {
375
459
  if (!executeBundle)
376
460
  return;
377
- // Bump the executeEpoch so a second Execute click invalidates the
378
- // in-flight Execute. compileEpoch is unaffected — the user can edit
379
- // the source mid-Execute without the running compile being torn down.
380
- const epoch = ++executeEpoch.current;
461
+ const attempt = executionLifecycle.current.begin();
462
+ const generation = compilerLifecycle.current.capture();
381
463
  setExecuting(true);
382
464
  setBundleError(null);
383
465
  // Clear the previous run's console output up front. Without this the
@@ -388,7 +470,7 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
388
470
  setConsoleMessages([]);
389
471
  const messages = [];
390
472
  const push = (type, args) => {
391
- if (executeEpoch.current !== epoch)
473
+ if (!attempt.isCurrent() || !generation.isCurrent())
392
474
  return;
393
475
  messages.push({ type, value: args });
394
476
  setConsoleMessages([...messages]);
@@ -403,20 +485,26 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
403
485
  const currentSource = latestSource.current;
404
486
  const currentVersion = sourceVersion.current;
405
487
  const dependencyError = await installDependenciesForSource(currentSource, currentVersion);
406
- if (executeEpoch.current !== epoch)
488
+ if (!attempt.isCurrent() || !generation.isCurrent())
407
489
  return;
408
490
  if (dependencyError) {
491
+ if (await recoverTerminalWorker(dependencyError, generation))
492
+ return;
409
493
  push("error", [dependencyError]);
410
494
  return;
411
495
  }
412
496
  const service = await createCompilerService();
497
+ if (!attempt.isCurrent() || !generation.isCurrent())
498
+ return;
413
499
  const compiled = await service.bundle({
414
500
  source: currentSource,
415
501
  options,
416
502
  });
417
- if (executeEpoch.current !== epoch)
503
+ if (!attempt.isCurrent() || !generation.isCurrent())
418
504
  return;
419
505
  if (compiled.type === "error") {
506
+ if (await recoverTerminalWorker(compiled.value, generation))
507
+ return;
420
508
  const message = typeof compiled.value === "string"
421
509
  ? compiled.value
422
510
  : (compiled.value?.message ??
@@ -439,22 +527,24 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
439
527
  await executeBundle(code, {
440
528
  console: sandboxConsole,
441
529
  runtimeFiles: runtimeDependencyFiles.current,
530
+ signal: attempt.signal,
442
531
  });
532
+ if (!generation.isCurrent())
533
+ return;
443
534
  }
444
535
  catch (error) {
445
536
  push("error", [error]);
446
537
  }
447
538
  }
448
539
  catch (error) {
449
- if (executeEpoch.current !== epoch)
540
+ if (!attempt.isCurrent() || !generation.isCurrent())
541
+ return;
542
+ if (await recoverTerminalWorker(error, generation))
450
543
  return;
451
544
  push("error", [error]);
452
545
  }
453
546
  finally {
454
- // Only the winning epoch clears the flag — same rationale as
455
- // compileEpoch above. With a separate executeEpoch this is robust
456
- // against concurrent click + edit interleavings.
457
- if (executeEpoch.current === epoch)
547
+ if (attempt.finish())
458
548
  setExecuting(false);
459
549
  }
460
550
  // `source` is intentionally NOT a dep: the body snapshots
@@ -467,6 +557,7 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
467
557
  executeBundle,
468
558
  installDependenciesForSource,
469
559
  options,
560
+ recoverTerminalWorker,
470
561
  ]);
471
562
  const allDiagnostics = (0, react_1.useMemo)(() => {
472
563
  const fromCompile = [];
@@ -534,25 +625,22 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
534
625
  return String(e);
535
626
  }
536
627
  })() }), (0, jsx_runtime_1.jsx)("button", { onClick: () => {
537
- // Each click bumps retryEpoch; only the latest retry's body
538
- // writes boot state. A double-click cancels the prior retry's
539
- // pending writes so it can't flip bootPhase to "ready"
540
- // against a connection the new retry has already torn down.
541
- const epoch = ++retryEpoch.current;
628
+ const failedGeneration = compilerLifecycle.current.capture();
629
+ const retryGeneration = invalidateCompilerGeneration("compiler retry requested", failedGeneration);
630
+ if (retryGeneration === undefined)
631
+ return;
542
632
  void (async () => {
543
633
  await client.reset();
544
- if (retryEpoch.current !== epoch)
634
+ if (!retryGeneration.isCurrent())
545
635
  return;
546
- setBootError(null);
547
- setBootPhase("booting");
548
636
  try {
549
637
  await createCompilerService();
550
- if (retryEpoch.current !== epoch)
638
+ if (!retryGeneration.isCurrent())
551
639
  return;
552
640
  setBootPhase("ready");
553
641
  }
554
642
  catch (err) {
555
- if (retryEpoch.current !== epoch)
643
+ if (!retryGeneration.isCurrent())
556
644
  return;
557
645
  setBootError(err);
558
646
  setBootPhase("failed");
@@ -572,7 +660,7 @@ function PlaygroundShell({ workerUrl, defaultScript, examples = [], exampleGroup
572
660
  : result.type === "error"
573
661
  ? (JSON.stringify(result.value, null, 2) ??
574
662
  String(result.value))
575
- : result.value })) })] })] }), bundleError && ((0, jsx_runtime_1.jsxs)("div", { className: "shrink-0 border-t border-red-300 bg-red-50 px-4 py-1.5 font-mono text-[11px] text-red-700", children: ["Bundle failed \u2014 ", bundleError] })), executeBundle && ((0, jsx_runtime_1.jsxs)("div", { className: "flex h-48 shrink-0 flex-col border-t border-[#c7dff4] bg-[#f7fbff]", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between border-b border-[#d8e7f4] px-4 py-1.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "font-mono text-[11px] text-slate-500", children: "console output" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [consoleMessages.length > 0 && ((0, jsx_runtime_1.jsx)("button", { onClick: () => setConsoleMessages([]), className: "px-2 py-1 font-mono text-[10px] text-slate-500 transition-colors hover:text-[#235a97]", children: "Clear" })), (0, jsx_runtime_1.jsxs)("button", { onClick: onExecute, disabled: executing, className: "rounded-md bg-[#3178c6] px-3 py-1 font-mono text-[11px] text-white transition-colors hover:bg-[#235a97] disabled:opacity-50", title: "Cmd/Ctrl+Enter", children: ["\u25B6 ", executing ? "Executing…" : "Execute"] })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex-1 min-h-0", children: (0, jsx_runtime_1.jsx)(ConsoleViewer_1.ConsoleViewer, { messages: consoleMessages }) })] })), (0, jsx_runtime_1.jsx)(DiagnosticsPanel_1.DiagnosticsPanel, { diagnostics: allDiagnostics }), optionsOpen && ((0, jsx_runtime_1.jsx)(OptionsPanel_1.OptionsPanel, { options: options, onChange: setOptions, onClose: () => setOptionsOpen(false), toggles: optionToggles })), (0, jsx_runtime_1.jsx)(DependencyProgressModal_1.DependencyProgressModal, { progress: dependencyProgress, packages: dependencyPackageNames })] }));
663
+ : result.value })) })] })] }), bundleError && ((0, jsx_runtime_1.jsxs)("div", { className: "shrink-0 border-t border-red-300 bg-red-50 px-4 py-1.5 font-mono text-[11px] text-red-700", children: ["Bundle failed \u2014 ", bundleError] })), executeBundle && ((0, jsx_runtime_1.jsxs)("div", { className: "flex h-48 shrink-0 flex-col border-t border-[#c7dff4] bg-[#f7fbff]", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between border-b border-[#d8e7f4] px-4 py-1.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "font-mono text-[11px] text-slate-500", children: "console output" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [consoleMessages.length > 0 && ((0, jsx_runtime_1.jsx)("button", { onClick: () => setConsoleMessages([]), className: "px-2 py-1 font-mono text-[10px] text-slate-500 transition-colors hover:text-[#235a97]", children: "Clear" })), (0, jsx_runtime_1.jsxs)("button", { onClick: onExecute, disabled: executing, className: "rounded-md bg-[#3178c6] px-3 py-1 font-mono text-[11px] text-white transition-colors hover:bg-[#235a97] disabled:opacity-50", title: "Cmd/Ctrl+Enter", children: ["\u25B6 ", executing ? "Executing…" : "Execute"] })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex-1 min-h-0", children: (0, jsx_runtime_1.jsx)(ConsoleViewer_1.ConsoleViewer, { messages: consoleMessages }) })] })), (0, jsx_runtime_1.jsx)(DiagnosticsPanel_1.DiagnosticsPanel, { diagnostics: allDiagnostics }), optionsOpen && ((0, jsx_runtime_1.jsx)(OptionsPanel_1.OptionsPanel, { options: options, onChange: updateOptions, onClose: () => setOptionsOpen(false), toggles: optionToggles })), (0, jsx_runtime_1.jsx)(DependencyProgressModal_1.DependencyProgressModal, { progress: dependencyProgress, packages: dependencyPackageNames })] }));
576
664
  }
577
665
  function defaultResultCaption(_options) {
578
666
  return "dist/playground.js";
@@ -587,12 +675,6 @@ function describeUnknownError(error) {
587
675
  return String(error);
588
676
  }
589
677
  }
590
- function normalizeClientError(error) {
591
- if (error instanceof Error) {
592
- return { name: error.name, message: error.message, stack: error.stack };
593
- }
594
- return { name: "Error", message: describeUnknownError(error) };
595
- }
596
678
  function wait(ms) {
597
679
  return new Promise((resolve) => window.setTimeout(resolve, ms));
598
680
  }