entries.client.spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. // @vitest-environment jsdom
  2. import { Context } from '@deepseek-ai/cordis'
  3. import Loader from '@deepseek-ai/cordis-plugin-loader'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import { apply as provideModules, createClientModuleSystem } from '../src/client/index.ts'
  6. import type { ClientBundleRegistration, ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
  7. const contexts: Context[] = []
  8. afterEach(async () => {
  9. for (const ctx of contexts.splice(0)) {
  10. await ctx.fiber.dispose()
  11. await ctx.fiber.await()
  12. }
  13. document.head.querySelectorAll('style').forEach((el) => { el.remove() })
  14. document.body.replaceChildren()
  15. vi.restoreAllMocks()
  16. })
  17. const row = (id: string, rev = 'r0', extra: Partial<WebBootEntry> = {}): WebBootEntry => ({
  18. id, rev, url: `/plugins/??${id}/client.js&rev=${rev}`, ...extra,
  19. })
  20. const graph = (...entries: WebBootEntry[]): WebBootGraph => ({
  21. rev: JSON.stringify(entries), entries,
  22. batches: entries.length === 0 ? [] : [{ phase: 'application', url: '/batch', rev: 'batch', entries: entries.map(row => row.id) }],
  23. })
  24. const deferred = () => {
  25. let resolve!: () => void
  26. const promise = new Promise<void>((done) => { resolve = done })
  27. return { promise, resolve }
  28. }
  29. async function bench(initial: WebBootGraph, factories: Record<string, ClientBundleRegistration['factory']>, start = true) {
  30. const ctx = new Context()
  31. contexts.push(ctx)
  32. const fetched: string[] = []
  33. const target: ClientModuleLoaderTarget = {
  34. mode: 'queue', pendingQueue: [], load: () => {},
  35. create: options => createClientModuleSystem(target, { id: 'bootstrap', exports: { inject: ['loader'], apply: provideModules } }, options),
  36. }
  37. let arrival: (url: string) => Promise<void> = async () => {}
  38. const modules = target.create({
  39. boot: initial, staticModules: {},
  40. loadBundle: async (url) => {
  41. fetched.push(url)
  42. const ids = url === '/batch' ? initial.entries.map(row => row.id).filter(id => id !== 'bootstrap') : [url.split('??')[1]!.split('/client.js')[0]!]
  43. const registrations = ids.map(id => ({ id, factory: factories[id]! }))
  44. await arrival(url)
  45. for (const registration of registrations) target.load(registration)
  46. },
  47. })
  48. await ctx.plugin(Loader)
  49. ctx.loader.internal = modules as never
  50. if (start) await modules.entries.start(ctx.loader, modules.manifest)
  51. return { ctx, modules, fetched, target, arrival: (fn: typeof arrival) => { arrival = fn } }
  52. }
  53. /** A visible plugin whose style and listener are owned by its factory and fiber respectively. */
  54. function visible(id: string, effects: { mounted: number; disposed: number; hits: number }, cleanup?: () => Promise<void>) {
  55. return () => {
  56. const style = document.createElement('style')
  57. style.dataset.plugin = id
  58. style.textContent = `[data-live="${id}"] { color: rgb(12, 34, 56); }`
  59. document.head.append(style)
  60. return { apply(ctx: Context) {
  61. ctx.effect(() => {
  62. effects.mounted++
  63. const el = document.createElement('div')
  64. el.dataset.live = id
  65. document.body.append(el)
  66. const listener = () => { effects.hits++ }
  67. window.addEventListener('live-test', listener)
  68. return async () => {
  69. window.removeEventListener('live-test', listener)
  70. el.remove()
  71. await cleanup?.()
  72. effects.disposed++
  73. }
  74. })
  75. } }
  76. }
  77. }
  78. describe('client manifest entries', () => {
  79. it('adds, drains removal and re-enables one instance with styles; unrelated entries survive', async () => {
  80. const effects = { mounted: 0, disposed: 0, hits: 0 }
  81. const cleanup = deferred()
  82. const b = await bench(graph(), { pet: visible('pet', effects, () => cleanup.promise) })
  83. b.target.load({ id: 'local', factory: () => ({ apply() {} }) })
  84. const localId = await b.ctx.loader.create({ name: 'local' })
  85. await b.modules.entries.sync(graph(row('pet')))
  86. expect(b.fetched).toEqual([row('pet').url])
  87. expect(document.querySelectorAll('[data-live=pet]')).toHaveLength(1)
  88. expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
  89. window.dispatchEvent(new Event('live-test'))
  90. expect(effects.hits).toBe(1)
  91. const removing = b.modules.entries.sync(graph())
  92. await vi.waitFor(() => { expect(document.querySelector('[data-live=pet]')).toBeNull() })
  93. const readding = b.modules.entries.sync(graph(row('pet')))
  94. expect(effects.disposed).toBe(0)
  95. expect(effects.mounted).toBe(1)
  96. cleanup.resolve()
  97. await Promise.all([removing, readding])
  98. expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 1 })
  99. expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
  100. expect(b.ctx.loader.resolve(localId).fiber?.state).toBe(2)
  101. await b.modules.entries.sync(graph())
  102. window.dispatchEvent(new Event('live-test'))
  103. expect(effects).toEqual({ mounted: 2, disposed: 2, hits: 1 })
  104. expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(0)
  105. expect(b.modules.loadCache.has('pet')).toBe(false)
  106. expect(b.modules.entries.state.getSnapshot()).toEqual({ syncing: false, failures: [] })
  107. })
  108. it('does not mount an obsolete download and can load it again later', async () => {
  109. const effects = { mounted: 0, disposed: 0, hits: 0 }
  110. const b = await bench(graph(), { pet: visible('pet', effects) })
  111. const arrival = deferred()
  112. const started = deferred()
  113. b.arrival(async () => { started.resolve(); await arrival.promise })
  114. const enabling = b.modules.entries.sync(graph(row('pet')))
  115. await started.promise
  116. const disabling = b.modules.entries.sync(graph())
  117. arrival.resolve()
  118. await Promise.all([enabling, disabling])
  119. expect(effects.mounted).toBe(0)
  120. expect(b.modules.loadCache.has('pet')).toBe(false)
  121. await b.modules.entries.sync(graph(row('pet')))
  122. expect(effects.mounted).toBe(1)
  123. expect(b.fetched).toEqual([row('pet').url, row('pet').url])
  124. })
  125. it('registers dynamic dependencies first and retains one still required by an unmanaged entry', async () => {
  126. const materialized = vi.fn<(id: string) => void>()
  127. const b = await bench(graph(row('existing')), {
  128. existing: () => { materialized('existing'); return { apply() {} } },
  129. dependency: () => { materialized('dependency'); return { apply() {}, value: 42 } },
  130. consumer: (require) => {
  131. materialized('consumer')
  132. expect(require('dependency/client')).toHaveProperty('value', 42)
  133. return { apply() {} }
  134. },
  135. })
  136. await b.modules.entries.sync(graph(row('existing'), row('consumer', 'r0', { external: ['dependency/client'] }), row('dependency')))
  137. expect(b.fetched.slice(1)).toEqual([row('dependency').url, row('consumer').url])
  138. expect(materialized.mock.calls.map(([id]) => id)).toEqual(['existing', 'consumer', 'dependency'])
  139. b.target.load({ id: 'local', factory: require => ({ apply() {}, dep: require('dependency/client') }) })
  140. const localId = await b.ctx.loader.create({ name: 'local' })
  141. await b.modules.entries.sync(graph(row('existing')))
  142. expect(b.modules.loadCache.has('consumer')).toBe(false)
  143. expect(b.modules.loadCache.has('dependency')).toBe(true)
  144. b.ctx.loader.remove(localId)
  145. await b.modules.entries.retry()
  146. expect(b.modules.loadCache.has('dependency')).toBe(false)
  147. expect(materialized.mock.calls.filter(([id]) => id === 'existing')).toHaveLength(1)
  148. })
  149. it('diagnoses download and apply failures, retries an identical graph and leaves healthy plugins running', async () => {
  150. const effects = { mounted: 0, disposed: 0, hits: 0 }
  151. let broken = true
  152. const b = await bench(graph(row('healthy')), {
  153. healthy: visible('healthy', effects),
  154. bad: () => ({ apply() { if (broken) throw new Error('apply unavailable') } }),
  155. })
  156. b.arrival(async () => { throw new Error('download unavailable') })
  157. await b.modules.entries.sync(graph(row('healthy'), row('bad')))
  158. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('download unavailable')
  159. b.arrival(async () => {})
  160. await b.modules.entries.retry()
  161. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('apply unavailable')
  162. broken = false
  163. await b.modules.entries.retry()
  164. expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
  165. expect(effects.mounted).toBe(1)
  166. expect(effects.disposed).toBe(0)
  167. })
  168. it('deduplicates rebuilt and graph revisions and retries after failed code arrival', async () => {
  169. const effects = { mounted: 0, disposed: 0, hits: 0 }
  170. const b = await bench(graph(row('pet')), { pet: visible('pet', effects) })
  171. await b.modules.entries.reload('pet', 'r1')
  172. await b.modules.entries.sync(graph(row('pet', 'r1')))
  173. expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
  174. b.arrival(async () => { throw new Error('offline') })
  175. await expect(b.modules.entries.reload('pet', 'r2')).rejects.toThrow('offline')
  176. expect(effects.mounted).toBe(2)
  177. b.arrival(async () => {})
  178. await b.modules.entries.retry()
  179. expect(effects).toEqual({ mounted: 3, disposed: 2, hits: 0 })
  180. expect(document.querySelectorAll('style[data-plugin=pet]')).toHaveLength(1)
  181. })
  182. it('rejects malformed graphs before mutating active entries', async () => {
  183. const b = await bench(graph(row('a')), { a: () => ({ apply() {} }) })
  184. expect(() => b.modules.entries.sync({ rev: 'bad', entries: [{ id: 'a' }], batches: [] })).toThrow('string id/url/rev')
  185. expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['a'])
  186. expect(() => b.modules.entries.start(b.ctx.loader, b.modules.manifest)).toThrow('already started')
  187. })
  188. })
  189. it('publishes stable local snapshots and contains a failing subscriber', async () => {
  190. const b = await bench(graph(), {})
  191. const error = vi.spyOn(console, 'error').mockImplementation(() => {})
  192. const listener = vi.fn()
  193. const removeBad = b.modules.entries.state.subscribe(() => { throw new Error('subscriber') })
  194. const remove = b.modules.entries.state.subscribe(listener)
  195. expect(b.modules.entries.state.getSnapshot()).toBe(b.modules.entries.state.getSnapshot())
  196. await b.modules.entries.retry()
  197. expect(listener).toHaveBeenCalledTimes(2)
  198. expect(error).toHaveBeenCalled()
  199. removeBad()
  200. remove()
  201. await b.modules.entries.retry()
  202. expect(listener).toHaveBeenCalledTimes(2)
  203. })
  204. it('ignores superseded queued snapshots and rebuilds of absent or unchanged entries', async () => {
  205. const b = await bench(graph(), { a: () => ({ apply() {} }) })
  206. const first = b.modules.entries.sync(graph(row('a')))
  207. const second = b.modules.entries.sync(graph())
  208. await Promise.all([first, second])
  209. expect(b.fetched).toEqual([])
  210. await b.modules.entries.reload('a', 'r1')
  211. await b.modules.entries.sync(graph(row('a')))
  212. await b.modules.entries.reload('a', 'r0')
  213. await b.ctx.fiber.dispose()
  214. await b.modules.entries.reload('a', 'r2')
  215. await b.modules.entries.sync(graph())
  216. expect(b.fetched).toHaveLength(1)
  217. })
  218. it('reports reconciliation before startup and missing service activation without losing later retries', async () => {
  219. const b = await bench(graph(), { pending: () => ({ inject: ['missing'], apply() {} }) }, false)
  220. await expect(b.modules.entries.sync(graph())).rejects.toThrow('have not started')
  221. await b.modules.entries.start(b.ctx.loader, b.modules.manifest)
  222. await b.modules.entries.sync(graph(row('pending')))
  223. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('waiting for activation')
  224. b.ctx.provide('missing', {})
  225. await b.modules.entries.retry()
  226. expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
  227. })
  228. it('retries a materialization failure with the same graph and cleans its partial styles', async () => {
  229. let broken = true
  230. const b = await bench(graph(), { a: () => {
  231. const style = document.createElement('style')
  232. style.dataset.plugin = 'a'
  233. document.head.append(style)
  234. if (broken) throw new Error('factory failed')
  235. return { apply() {} }
  236. } })
  237. await b.modules.entries.sync(graph(row('a')))
  238. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
  239. expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(0)
  240. await b.modules.entries.retry()
  241. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
  242. await b.modules.entries.reload('a', 'r1').catch(() => {})
  243. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('factory failed')
  244. broken = false
  245. await b.modules.entries.retry()
  246. expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
  247. expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
  248. })
  249. it('does not finish a stale code replacement after download or asynchronous teardown', async () => {
  250. const effects = { mounted: 0, disposed: 0, hits: 0 }
  251. const cleanup = deferred()
  252. const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise) })
  253. const download = deferred()
  254. const started = deferred()
  255. b.arrival(async () => { started.resolve(); await download.promise })
  256. const rebuilding = b.modules.entries.reload('a', 'r1')
  257. await started.promise
  258. const snapshot = b.modules.entries.sync(graph(row('a')))
  259. download.resolve()
  260. await Promise.all([rebuilding, snapshot])
  261. expect(effects.mounted).toBe(1)
  262. const swapping = b.modules.entries.reload('a', 'r2')
  263. await vi.waitFor(() => { expect(document.querySelector('[data-live=a]')).toBeNull() })
  264. const disabling = b.modules.entries.sync(graph())
  265. cleanup.resolve()
  266. await Promise.all([swapping, disabling])
  267. expect(effects.mounted).toBe(1)
  268. expect(b.modules.loadCache.has('a')).toBe(false)
  269. })
  270. it('stops an obsolete multi-entry application after awaiting removal', async () => {
  271. const cleanup = deferred()
  272. const effects = { mounted: 0, disposed: 0, hits: 0 }
  273. const b = await bench(graph(row('a')), { a: visible('a', effects, () => cleanup.promise), b: () => ({ apply() {} }) })
  274. const first = b.modules.entries.sync(graph(row('b')))
  275. await vi.waitFor(() => { expect(document.querySelector('[data-live=a]')).toBeNull() })
  276. const latest = b.modules.entries.sync(graph())
  277. cleanup.resolve()
  278. await Promise.all([first, latest])
  279. expect(b.fetched).toEqual(['/batch'])
  280. })
  281. it('keeps bootstrap ownership explicit and diagnoses removal without changing entries', async () => {
  282. const b = await bench(graph(row('bootstrap')), {})
  283. await expect(b.modules.entries.sync(graph())).rejects.toThrow('removing bootstrap module')
  284. expect([...b.ctx.loader.entries()].map(entry => entry.options.name)).toEqual(['bootstrap'])
  285. expect(b.modules.entries.state.getSnapshot().syncing).toBe(false)
  286. await b.modules.entries.sync(graph(row('bootstrap')))
  287. expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
  288. })
  289. it('retains unrelated style tags while reporting a failed replacement', async () => {
  290. let broken = false
  291. const effects = { mounted: 0, disposed: 0, hits: 0 }
  292. const b = await bench(graph(row('a')), { a: () => {
  293. if (broken) throw new Error('changed factory failed')
  294. return visible('a', effects)()
  295. } })
  296. const unrelated = document.createElement('style')
  297. unrelated.dataset.plugin = 'unrelated'
  298. document.head.append(unrelated)
  299. broken = true
  300. await b.modules.entries.sync(graph(row('a', 'r1')))
  301. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('changed factory failed')
  302. expect(unrelated.isConnected).toBe(true)
  303. })
  304. it('keeps unrelated page failures visible when a rebuilt download fails', async () => {
  305. const b = await bench(graph(row('a'), row('bad')), {
  306. a: () => ({ apply() {} }),
  307. bad: () => ({ apply() { throw new Error('bad apply') } }),
  308. })
  309. await b.modules.entries.retry()
  310. b.arrival(async () => { throw new Error('a download') })
  311. await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('a download')
  312. expect(b.modules.entries.state.getSnapshot().failures.map(failure => failure.id)).toEqual(['bad', 'a'])
  313. })
  314. it('retains a rejected Loader entry for retry instead of creating an orphan sibling', async () => {
  315. let broken = true
  316. const b = await bench(graph(), { a: () => broken ? { default: 'invalid' } : { apply() {} } })
  317. await b.modules.entries.sync(graph(row('a')))
  318. const entry = [...b.ctx.loader.entries()][0]!
  319. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('invalid plugin')
  320. expect([...b.ctx.loader.entries()]).toHaveLength(1)
  321. broken = false
  322. await b.modules.entries.retry()
  323. expect([...b.ctx.loader.entries()]).toEqual([entry])
  324. expect(entry.fiber?.state).toBe(2)
  325. })
  326. it('reports Loader import failures and recovers the same entry after the importer recovers', async () => {
  327. const b = await bench(graph(), { a: () => ({ apply() {} }) })
  328. let broken = true
  329. b.ctx.loader.internal = {
  330. version: 'client', import: async (id: string) => {
  331. if (broken) throw new Error('Loader import failed')
  332. return b.modules.import(id)
  333. },
  334. } as never
  335. await b.modules.entries.sync(graph(row('a')))
  336. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('import failed')
  337. const entry = [...b.ctx.loader.entries()][0]!
  338. broken = false
  339. await b.modules.entries.retry()
  340. expect(entry.fiber?.state).toBe(2)
  341. broken = true
  342. await expect(b.modules.entries.reload('a', 'r1')).rejects.toThrow('import failed')
  343. expect(b.modules.entries.state.getSnapshot().failures[0]?.id).toBe('a')
  344. })
  345. it.each([false, true])('does not mount a materialized factory after a newer removal (rebuild: %s)', async (rebuild) => {
  346. let stop: Promise<void> | undefined
  347. let materializations = 0
  348. let mounted = 0
  349. const factories = { a: () => {
  350. materializations++
  351. if (materializations === (rebuild ? 2 : 1)) {
  352. queueMicrotask(() => { stop = b.modules.entries.sync(graph()) })
  353. }
  354. return { apply() { mounted++ } }
  355. } }
  356. const b = await bench(graph(), factories)
  357. if (rebuild) {
  358. // The first materialization remains live until the replacement reaches the import barrier.
  359. materializations = -1
  360. await b.modules.entries.sync(graph(row('a')))
  361. materializations = 1
  362. await b.modules.entries.reload('a', 'r1')
  363. } else {
  364. await b.modules.entries.sync(graph(row('a')))
  365. }
  366. await stop
  367. expect(mounted).toBe(rebuild ? 1 : 0)
  368. expect([...b.ctx.loader.entries()]).toHaveLength(0)
  369. })
  370. it('coalesces an overlapping graph snapshot with the same rebuilt artifact', async () => {
  371. const effects = { mounted: 0, disposed: 0, hits: 0 }
  372. const b = await bench(graph(row('a')), { a: visible('a', effects) })
  373. const download = deferred()
  374. const started = deferred()
  375. b.arrival(async () => { started.resolve(); await download.promise })
  376. const rebuilding = b.modules.entries.reload('a', 'r1')
  377. await started.promise
  378. const syncing = b.modules.entries.sync(graph(row('a', 'r1')))
  379. download.resolve()
  380. await Promise.all([rebuilding, syncing])
  381. expect(b.fetched).toEqual(['/batch', row('a', 'r1').url])
  382. expect(effects).toEqual({ mounted: 2, disposed: 1, hits: 0 })
  383. })
  384. it.each(['graph', 'rebuilt'])('replaces a failed factory before entry creation on a new %s revision', async (source) => {
  385. const effects = { mounted: 0, disposed: 0, hits: 0 }
  386. const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => { throw new Error('broken r0 factory') } }
  387. const b = await bench(graph(), factories)
  388. await b.modules.entries.sync(graph(row('a')))
  389. expect([...b.ctx.loader.entries()]).toHaveLength(0)
  390. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('broken r0 factory')
  391. factories.a = () => ({ ...visible('a', effects)(), revision: 'r1' })
  392. if (source === 'graph') await b.modules.entries.sync(graph(row('a', 'r1')))
  393. else await b.modules.entries.reload('a', 'r1')
  394. await b.modules.entries.retry()
  395. expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
  396. expect(await b.modules.import('a')).toHaveProperty('revision', 'r1')
  397. expect(document.querySelectorAll('[data-live=a]')).toHaveLength(1)
  398. expect(effects.mounted).toBe(1)
  399. expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
  400. })
  401. it('replaces a superseded arrival and its cached dependency before mounting the latest code', async () => {
  402. const dependency = row('dependency')
  403. const consumer = row('consumer', 'r0', { external: ['dependency/client'] })
  404. const factories: Record<string, ClientBundleRegistration['factory']> = {
  405. dependency: () => ({ apply() {}, revision: 'r0' }),
  406. consumer: require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r0' }),
  407. }
  408. const b = await bench(graph(), factories)
  409. const started = deferred()
  410. const download = deferred()
  411. b.arrival(async (url) => { if (url === consumer.url) { started.resolve(); await download.promise } })
  412. const old = b.modules.entries.sync(graph(consumer, dependency))
  413. try {
  414. await started.promise
  415. factories.dependency = () => ({ apply() {}, revision: 'r1' })
  416. factories.consumer = require => ({ apply() {}, dependency: require('dependency/client'), revision: 'r1' })
  417. const latest = b.modules.entries.sync(graph(row('consumer', 'r1', { external: ['dependency/client'] }), row('dependency', 'r1')))
  418. download.resolve()
  419. await Promise.all([old, latest])
  420. expect(await b.modules.import('consumer')).toMatchObject({ revision: 'r1', dependency: { revision: 'r1' } })
  421. expect(b.fetched).toEqual([dependency.url, consumer.url, row('dependency', 'r1').url, row('consumer', 'r1').url])
  422. expect(b.modules.entries.state.getSnapshot().failures).toEqual([])
  423. } finally {
  424. download.resolve()
  425. await old
  426. }
  427. })
  428. it.each(['graph', 'rebuilt'])('preserves bootstrap and dependent fibers when a %s requests new bootstrap code', async (source) => {
  429. const effects = { mounted: 0, disposed: 0, hits: 0 }
  430. const b = await bench(graph(row('bootstrap'), row('consumer')), {
  431. consumer: () => ({ ...visible('consumer', effects)(), inject: ['modules'] }),
  432. })
  433. const fibers = [...b.ctx.loader.entries()].map(entry => entry.fiber)
  434. const exports = await b.modules.import('bootstrap')
  435. if (source === 'graph') await b.modules.entries.sync(graph(row('bootstrap', 'r1'), row('consumer')))
  436. else await expect(b.modules.entries.reload('bootstrap', 'r1')).rejects.toThrow('requires a page reload')
  437. for (let retry = 0; retry < 2; retry++) {
  438. await b.modules.entries.retry()
  439. expect(b.modules.entries.state.getSnapshot().failures).toEqual([
  440. { id: 'bootstrap', message: 'Error: client-modules: replacing bootstrap module bootstrap requires a page reload' },
  441. ])
  442. }
  443. expect([...b.ctx.loader.entries()].map(entry => entry.fiber)).toEqual(fibers)
  444. expect(await b.modules.import('bootstrap')).toBe(exports)
  445. expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
  446. expect(b.fetched).toEqual(['/batch'])
  447. })
  448. it('discards a failed arrival target when an uncreated entry receives a newer graph', async () => {
  449. const b = await bench(graph(), { a: () => { throw new Error('r0 factory') } })
  450. await b.modules.entries.sync(graph(row('a')))
  451. b.arrival(async () => { throw new Error('offline r1') })
  452. await b.modules.entries.reload('a', 'r1')
  453. expect(b.modules.entries.state.getSnapshot().failures[0]?.message).toContain('offline r1')
  454. b.arrival(async () => {})
  455. await b.modules.entries.sync(graph(row('a', 'r2')))
  456. expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url, row('a', 'r2').url])
  457. })
  458. it('uses the latest desired revision when a rebuild queues before entry creation', async () => {
  459. const b = await bench(graph(), { a: () => ({ apply() {} }) })
  460. const adding = b.modules.entries.sync(graph(row('a')))
  461. const rebuilding = b.modules.entries.reload('a', 'r1')
  462. const latest = b.modules.entries.sync(graph(row('a', 'r2')))
  463. await Promise.all([adding, rebuilding, latest])
  464. expect(b.fetched).toEqual([row('a', 'r2').url])
  465. expect([...b.ctx.loader.entries()]).toHaveLength(1)
  466. })
  467. it('cleans styles from a materialized factory superseded before its entry is created', async () => {
  468. const effects = { mounted: 0, disposed: 0, hits: 0 }
  469. let latest: Promise<void> | undefined
  470. const factories: Record<string, ClientBundleRegistration['factory']> = { a: () => {
  471. const old = visible('a', effects)()
  472. queueMicrotask(() => {
  473. factories.a = visible('a', effects)
  474. latest = b.modules.entries.sync(graph(row('a', 'r1')))
  475. })
  476. return old
  477. } }
  478. const b = await bench(graph(), factories)
  479. await b.modules.entries.sync(graph(row('a')))
  480. await latest
  481. expect(b.fetched).toEqual([row('a').url, row('a', 'r1').url])
  482. expect(effects).toEqual({ mounted: 1, disposed: 0, hits: 0 })
  483. expect(document.querySelectorAll('style[data-plugin=a]')).toHaveLength(1)
  484. })