Skip to content

Commit 3c30a41

Browse files
authored
Merge pull request #192 from shopware/feat/canvas-readiness
feat: canvas readiness
2 parents 0d25154 + 0ef2bb2 commit 3c30a41

File tree

14 files changed

+1143
-461
lines changed

14 files changed

+1143
-461
lines changed

AGENTS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,30 @@
5656
- State action migrations must use `AnimationSystem.fromTargets(...).play()` and `Toolbox.enableTool()`; lingering `animate()` or `useTool()` calls can still let `yarn build` exit 0 while `vite-plugin-dts` reports TS2339 API drift
5757
- When swapping canvases under WebGPU, `DIVEEnvironment.setRenderer()` must run before disposing the previous `WebGPURenderer`; disposing the old renderer first can crash `PMREMGenerator.dispose()` inside Three's `NodeManager.delete` with `usedTimes` access errors
5858
- `OrientationDisplay.tick()` should size its overlay viewport from `DIVERenderer.canvas.clientHeight` and restore the prior `webgpurenderer.autoClear` value; unit tests can fall back to the saved viewport height when the mock omits `canvas`
59+
- Neighboring `dive-demo` local verification can use a `node_modules/@shopware-ag/dive` symlink to this repo when `yalc` is absent, as long as this repo's `build/` artifacts are present
60+
- The `dive-demo` orientation display example now uses a single `QuickView` canvas with `displayAxes: true`; the previous side-by-side comparison against a manually wired `OrientationDisplay` plugin is no longer the expected snapshot shape
61+
- `dive-demo` views that gate UI interactivity on `QuickView` readiness, such as `DiveSwitchCanvas` and `DiveTargetAnimation`, need to wait for a non-zero canvas layout plus a small initial delay before constructing `QuickView`; on CI `Linux + xvfb + llvmpipe`, starting too early leaves control buttons permanently disabled
62+
- In `dive-demo`, replacing the fixed QuickView startup sleep with a shared layout-driven wait helper (`ResizeObserver` plus animation-frame verification) keeps the initial load stable, but canvas-switch flows still need to yield one DOM frame after committing the active-panel state before calling `mainView.setCanvas(...)`
63+
- `DIVECanvasLifecycleManager` in `src/engine/canvas/` is again the single owner of canvas readiness state: it keeps the waiter promises, resolves `waitForHealthyCanvas()`, and advances readiness via its own `tick()`
64+
- `DIVECanvasLifecycleManager.tick()` must early-return while the current canvas remains valid; only invalid, detached, or freshly swapped canvases should re-enter the two-sample stabilization path
65+
- `DIVECanvasLifecycleManager.tick()` should stay as a shallow entrypoint that does the dispose guard and then delegates the actual lifecycle progression to the private `_checkCanvasHealth()` helper for readability
66+
- `DIVEView.tick()` should always call `DIVECanvasLifecycleManager.tick()` before honoring the paused/render path so canvas readiness can continue progressing even while rendering is paused
67+
- `DIVECanvasLifecycleManager` keeps its layout/readiness helpers as private member methods instead of top-level module helpers, so the canvas lifecycle logic stays co-located inside the class
68+
- `DIVEView.init()`, `DIVERenderer.init()`, and `DIVEEnvironment.init()` should stay `async` and explicitly `await` their cached `_initPromise` values; this repo prefers the consistent async method shape over collapsing those branches to direct promise returns
69+
- `DIVECanvasLifecycleManager.waitForHealthyCanvas()` can take an optional `AbortSignal`; aborting resolves only that individual waiter with `null`, while the CLM's shared readiness state keeps progressing through later `tick()` calls
70+
- `DIVEView` now uses an internal `AbortController` to invalidate pending init work on `dispose()` and `setCanvas()`; even with abort support, `renderer !== this._renderer` remains as the stale-renderer guard after awaited renderer initialization
71+
- `DIVERenderer` no longer owns DOM/canvas readiness logic; it only initializes WebGPU/environment state, swaps canvases, and handles render/resize calls
72+
- The old `DIVEResizeManager` compatibility layer has been removed entirely on v3; canvas ownership now lives directly between `DIVEView` and `DIVECanvasLifecycleManager`
73+
- `DIVEView.setCanvas()` must not force an immediate `onResize()` on the swapped canvas; the `DIVECanvasLifecycleManager` is the single source of truth for resize propagation
74+
- `DIVECanvasLifecycleManager.setCanvas()` must reset its cached width/height so an equally sized replacement canvas still emits the initial resize sync for the new renderer/camera pair
75+
- In `DIVECanvasLifecycleManager`, keep raw measurement in `_getCanvasLayout()` and the valid-layout fast path inside `waitForHealthyCanvas()`/`tick()`; there is no longer a separate public readiness accessor
76+
- `DIVEView` should pass a named `_handleCanvasResize` callback into `DIVECanvasLifecycleManager` instead of an inline lambda, so the renderer/camera resize orchestration stays explicit while the CLM remains decoupled
77+
- `DIVEView` invalidation branches after async init are best covered by disposing the view while `renderer.init()` is still pending and by invoking the `DIVECanvasLifecycleManager` resize callback directly to assert the `onResize` + immediate render path
78+
- `DIVECanvasLifecycleManager` keeps a single steady-state `ResizeObserver` on the canvas itself; parent changes are handled in `tick()` as validity invalidations rather than as observed resize events
79+
- `DIVEView` does not inject the clock into `DIVECanvasLifecycleManager`; only `DIVEView` itself is a `DIVETicker`, and `DIVE.startAsync()` must start the `DIVEClock` before awaiting `mainView.init()` so the CLM's internal `tick()` can progress inside the view loop
80+
- `DIVECanvasLifecycleManager` coverage is easiest to keep at 100% with explicit `tick()` advancement in tests, observer invalidation cases, and signal-based waiter success/stale-resolution assertions
81+
- In `View.test.ts`, the `waitForHealthyCanvas` mock should be explicitly typed as `Promise<DIVECanvasLayout | null>`; otherwise the stale `null` path triggers a TypeScript error on `mockResolvedValue(null)`
82+
- `DIVECanvasLifecycleManager` now keeps its shared waiter state under `_healthyCanvasPromise` and `_resolveHealthyCanvas` so the promise naming matches the canvas-health narrative
83+
- In `Dive.test.ts`, `mainView.init` is typed as a plain async method, so tests should narrow it with `vi.mocked(...)` before calling mock-only helpers like `mockRejectedValueOnce` or `mockImplementationOnce`
84+
- Full focused coverage for `CanvasLifecycleManager.ts` now needs explicit tests for parentless bootstrap polling, renderable-to-zero resets during stabilization, same-size canvas swaps, waiter-only aborts, and the private direct-layout fallback after bootstrap completion
85+
- Focused single-file coverage in this repo should use `vitest --coverage.include=<path>`; running one suite with the default global `src/**/*` coverage scope still enforces repo-wide thresholds and will fail even when the targeted file itself is at 100%

src/engine/Dive.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,15 +205,31 @@ export class DIVE {
205205
return;
206206
}
207207

208-
if (!this.mainView.renderer.initialized) {
209-
await this.mainView.renderer.init();
208+
if (this.mainView.renderer.initialized) {
209+
this._clock.start();
210+
} else {
211+
const initPromise = this.mainView.init();
212+
213+
queueMicrotask(() => {
214+
if (!this._disposed) {
215+
this._clock.start();
216+
}
217+
});
218+
219+
try {
220+
await initPromise;
221+
} catch (error) {
222+
this.dispose();
223+
console.error(
224+
'DIVE.startAsync: Failed to initialize. Error:',
225+
error,
226+
);
227+
}
210228
}
211229

212230
if (this._disposed) {
213231
return;
214232
}
215-
216-
this._clock.start();
217233
}
218234

219235
public stop(): void {

src/engine/__test__/Dive.test.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ vi.mock('../view/View.ts', async (importOriginal) => {
2424
const actual = await importOriginal<typeof import('../view/View.ts')>();
2525
return {
2626
...actual,
27-
DIVEView: vi.fn(function (this: any) {
27+
DIVEView: vi.fn(function (this: any, _scene, _camera, _settings) {
2828
const renderer = {
2929
initialized: false,
3030
canvas: {
@@ -34,15 +34,15 @@ vi.mock('../view/View.ts', async (importOriginal) => {
3434
height: 100,
3535
}),
3636
},
37-
init: vi.fn(() => {
38-
renderer.initialized = true;
39-
return Promise.resolve();
40-
}),
4137
dispose: vi.fn(),
4238
onResize: vi.fn(),
4339
render: vi.fn(),
4440
setCanvas: vi.fn(),
4541
};
42+
this.init = vi.fn(() => {
43+
renderer.initialized = true;
44+
return Promise.resolve();
45+
});
4646
this.dispose = vi.fn();
4747
this.onResize = vi.fn();
4848
this.tick = vi.fn();
@@ -54,6 +54,7 @@ vi.mock('../view/View.ts', async (importOriginal) => {
5454
},
5555
};
5656
this.canvas = renderer.canvas;
57+
this.dispose = vi.fn();
5758
return this;
5859
}),
5960
};
@@ -232,6 +233,13 @@ describe('DIVE', () => {
232233
expect(dive).toBeDefined();
233234
});
234235

236+
it('should register the main view with the clock', () => {
237+
const dive = new DIVE();
238+
239+
expect(dive.clock.addTicker).toHaveBeenCalledTimes(1);
240+
expect(dive.clock.addTicker).toHaveBeenCalledWith(dive.mainView);
241+
});
242+
235243
it('should instantiate in development DIVE_NODE_ENV', () => {
236244
process.env.DIVE_NODE_ENV = 'development';
237245
const dive = new DIVE();
@@ -298,6 +306,10 @@ describe('DIVE', () => {
298306
const dive = new DIVE(settings);
299307
await waitForAsync();
300308
expect(dive['_orientationDisplay']).toBeDefined();
309+
expect(dive.clock.addTicker).toHaveBeenCalledTimes(2);
310+
expect(dive.clock.addTicker).toHaveBeenCalledWith(
311+
dive['_orientationDisplay'],
312+
);
301313
});
302314

303315
it('should not initialize axis camera when displayAxes is false', () => {
@@ -359,12 +371,12 @@ describe('DIVE', () => {
359371
autoStart: false,
360372
});
361373

362-
dive.mainView.renderer.init.mockRejectedValueOnce(error);
374+
vi.mocked(dive.mainView.init).mockRejectedValueOnce(error);
363375
dive.start();
364376
await waitForAsync();
365377

366378
expect(errorSpy).toHaveBeenCalledWith(
367-
'DIVE.start: Failed to initialize the WebGPU renderer.',
379+
'DIVE.startAsync: Failed to initialize. Error:',
368380
error,
369381
);
370382
});
@@ -376,7 +388,7 @@ describe('DIVE', () => {
376388

377389
await dive.startAsync();
378390

379-
expect(dive.mainView.renderer.init).toHaveBeenCalled();
391+
expect(dive.mainView.init).toHaveBeenCalled();
380392
expect(dive.clock.start).toHaveBeenCalled();
381393
});
382394

@@ -386,26 +398,26 @@ describe('DIVE', () => {
386398
expect(dive.clock.stop).toHaveBeenCalled();
387399
});
388400

389-
it('should not start the clock after dispose when renderer init resolves late', async () => {
401+
it('should queue the clock start while renderer init is pending', async () => {
390402
const dive = new DIVE({
391403
autoStart: false,
392404
});
393405
let resolveInit: (() => void) | undefined;
394406

395-
dive.mainView.renderer.init.mockImplementationOnce(
407+
vi.mocked(dive.mainView.init).mockImplementationOnce(
396408
() =>
397409
new Promise<void>((resolve) => {
398410
resolveInit = resolve;
399411
}),
400412
);
401413

402414
const pendingStart = dive.startAsync();
403-
await dive.dispose();
415+
await Promise.resolve();
416+
417+
expect(dive.clock.start).toHaveBeenCalledTimes(1);
404418

405419
resolveInit?.();
406420
await pendingStart;
407-
408-
expect(dive.clock.start).not.toHaveBeenCalled();
409421
});
410422

411423
it('should get the canvas', () => {

0 commit comments

Comments
 (0)