@komaci/prefetch 262.21.0 → 264.4.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.
@@ -876,5 +876,103 @@ describe('prefetchService', () => {
876
876
  }, 0);
877
877
  });
878
878
  });
879
+ describe('bug repro: stack growth when bulk resolvers complete synchronously', () => {
880
+ afterEach(() => {
881
+ jest.restoreAllMocks();
882
+ });
883
+ it('should not recurse synchronously through bulkResolveGuard between chunks', async () => {
884
+ const MAX_CONCURRENT = 10;
885
+ const NUM_ADDRESSES = 500;
886
+ const EXPECTED_CHUNKS = NUM_ADDRESSES / MAX_CONCURRENT;
887
+ const pageRefs = Array.from({ length: NUM_ADDRESSES }, (_, i) => createPageRef('action1', String(i)));
888
+ const { createRouter } = setupRouterMock();
889
+ let currentSyncDepth = 0;
890
+ let maxSyncDepth = 0;
891
+ let startCallCount = 0;
892
+ // Simulate a bulk resolver whose constituents all complete synchronously
893
+ // (e.g. an empty LWC with no wire adapters).
894
+ jest.spyOn(ResolverApi, 'getBulkAdgResolver').mockImplementation((_adg, inputs, callbacks) => {
895
+ let completed = 0;
896
+ const total = inputs.length;
897
+ return {
898
+ start: () => {
899
+ startCallCount++;
900
+ currentSyncDepth++;
901
+ // This finds the maximum sync depth reached by any chunk.
902
+ if (currentSyncDepth > maxSyncDepth) {
903
+ maxSyncDepth = currentSyncDepth;
904
+ }
905
+ for (let i = 0; i < total; i++) {
906
+ completed++;
907
+ // This is where would synchronously recurse into then next chunk's start() without the fix in place.
908
+ callbacks.bulkResolutionStatusCb({
909
+ index: i,
910
+ status: {
911
+ getState: () => 'done',
912
+ getErrorMessages: () => [],
913
+ },
914
+ });
915
+ }
916
+ currentSyncDepth--;
917
+ },
918
+ areAllResolversDone: () => completed === total,
919
+ stop: jest.fn(),
920
+ };
921
+ });
922
+ const setup = setupPrefetch(createRouter, pageRefs, {
923
+ ...mockPrefetchConfig,
924
+ maxConcurrent: MAX_CONCURRENT,
925
+ });
926
+ const service = setup.run();
927
+ await waitForExpect(() => {
928
+ expect(service.getState()).toBe('done');
929
+ });
930
+ // All chunks must have actually been started.
931
+ expect(startCallCount).toBe(EXPECTED_CHUNKS);
932
+ // Each chunk's bulk resolver should start on its own fresh call stack.
933
+ // Without that boundary, maxSyncDepth grows linearly with chunk count,
934
+ // which is the root cause of the stack overflow observed in production
935
+ expect(maxSyncDepth).toBe(1);
936
+ }, 10000);
937
+ it('completes successfully when enough chunks resolve synchronously (regression test for stack overflow)', async () => {
938
+ const MAX_CONCURRENT = 10;
939
+ // Previously enough chunks to blow past V8's default stack limit
940
+ // (~10-12k frames). Each chunk added ~15-25 JS frames on the
941
+ // synchronous cascade from bulkResolveGuard -> ... -> start().
942
+ // With the fix, the microtask boundary prevents the recursion and
943
+ // all 20k addresses should resolve without a RangeError.
944
+ const NUM_ADDRESSES = 20000;
945
+ const pageRefs = Array.from({ length: NUM_ADDRESSES }, (_, i) => createPageRef('action1', String(i)));
946
+ const { createRouter } = setupRouterMock();
947
+ jest.spyOn(ResolverApi, 'getBulkAdgResolver').mockImplementation((_adg, inputs, callbacks) => {
948
+ let completed = 0;
949
+ const total = inputs.length;
950
+ return {
951
+ start: () => {
952
+ for (let i = 0; i < total; i++) {
953
+ completed++;
954
+ callbacks.bulkResolutionStatusCb({
955
+ index: i,
956
+ status: {
957
+ getState: () => 'done',
958
+ getErrorMessages: () => [],
959
+ },
960
+ });
961
+ }
962
+ },
963
+ areAllResolversDone: () => completed === total,
964
+ stop: jest.fn(),
965
+ };
966
+ });
967
+ const setup = setupPrefetch(createRouter, pageRefs, {
968
+ ...mockPrefetchConfig,
969
+ maxConcurrent: MAX_CONCURRENT,
970
+ });
971
+ const service = setup.run();
972
+ await waitForExpect(() => {
973
+ expect(service.getState()).toBe('done');
974
+ });
975
+ }, 30000);
976
+ });
879
977
  });
880
978
  //# sourceMappingURL=prefetchService.spec.js.map
@@ -390,8 +390,9 @@ export class PrefetchService {
390
390
  if (allDone) {
391
391
  if (this.adgMap.size > 0) {
392
392
  // if all current runs are all done but with addresses left in the adgMap
393
- // go process them
394
- this.bulkResolveGuard();
393
+ // go process them. Schedule via microtask to avoid unbounded synchronous
394
+ // recursion when resolvers complete synchronously (W-22146685).
395
+ Promise.resolve().then(() => this.bulkResolveGuard());
395
396
  }
396
397
  else {
397
398
  // prefetch is done when allIdel and nothing left in adgMap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@komaci/prefetch",
3
- "version": "262.21.0",
3
+ "version": "264.4.0",
4
4
  "description": "Komaci prefetch service.",
5
5
  "homepage": "https://komaci.dev/",
6
6
  "repository": {