@pyreon/server 0.15.0 → 0.16.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.
package/src/client.ts CHANGED
@@ -29,7 +29,16 @@ import type { ComponentFn } from '@pyreon/core'
29
29
  import { h } from '@pyreon/core'
30
30
  import { createRouter, hydrateLoaderData, type RouteRecord, RouterProvider } from '@pyreon/router'
31
31
  import { hydrateRoot, mount } from '@pyreon/runtime-dom'
32
- import type { HydrationStrategy } from './island'
32
+ import type { HydrationStrategy, PrefetchStrategy } from './island'
33
+
34
+ // Dev-time counter sink — see packages/internals/perf-harness for contract.
35
+ // Same pattern as @pyreon/runtime-dom: bare process.env.NODE_ENV gate (the
36
+ // bundler-agnostic library standard) so counter strings tree-shake out under
37
+ // every modern bundler (Vite, Webpack/Next.js, Rolldown, esbuild, Rollup,
38
+ // Parcel, Bun) when consumers ship a production bundle. The optional-chain
39
+ // short-circuits in dev when no consumer has called perfHarness.install().
40
+ const __DEV__ = process.env.NODE_ENV !== 'production'
41
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
33
42
 
34
43
  // ─── Full app hydration ──────────────────────────────────────────────────────
35
44
 
@@ -53,6 +62,9 @@ export interface StartClientOptions {
53
62
  * Returns a cleanup function that unmounts the app.
54
63
  */
55
64
  export function startClient(options: StartClientOptions): () => void {
65
+ // SSR-only guard: hard-fails if startClient is called server-side. Cannot be
66
+ // exercised under happy-dom (document is always defined in the test env).
67
+ /* v8 ignore next 3 */
56
68
  if (typeof document === 'undefined') {
57
69
  throw new Error('[Pyreon] startClient() can only be called in the browser.')
58
70
  }
@@ -91,19 +103,24 @@ type IslandLoader = () => Promise<{ default: ComponentFn } | ComponentFn>
91
103
  * Hydrate all `<pyreon-island>` elements on the page.
92
104
  *
93
105
  * Only loads JavaScript for components that are actually present in the HTML.
94
- * Respects hydration strategies (load, idle, visible, media, never).
106
+ * Respects hydration strategies (load, idle, visible, media, never). Returns
107
+ * a cleanup function that disconnects any pending observers/listeners.
108
+ *
109
+ * **`hydrate: 'never'` islands do NOT require a registry entry** — the whole
110
+ * point of the strategy is shipping zero client JS, so importing the loader
111
+ * (which would pull the component into the client bundle graph) defeats it.
112
+ * Such islands are silently skipped here without a `data-island-error` flag.
95
113
  *
96
114
  * @example
97
115
  * hydrateIslands({
98
116
  * Counter: () => import("./Counter"),
99
117
  * Search: () => import("./Search"),
118
+ * // No entry for `StaticBadge` even though it appears as a never-island
119
+ * // in the HTML — registering one would defeat the strategy.
100
120
  * })
101
121
  */
102
- /**
103
- * Hydrate all `<pyreon-island>` elements on the page.
104
- * Returns a cleanup function that disconnects any pending observers/listeners.
105
- */
106
122
  export function hydrateIslands(registry: Record<string, IslandLoader>): () => void {
123
+ /* v8 ignore next */
107
124
  if (typeof document === 'undefined') return () => {}
108
125
  const islands = document.querySelectorAll('pyreon-island')
109
126
  const cleanups: (() => void)[] = []
@@ -112,15 +129,52 @@ export function hydrateIslands(registry: Record<string, IslandLoader>): () => vo
112
129
  const componentId = el.getAttribute('data-component')
113
130
  if (!componentId) continue
114
131
 
132
+ // Detect nested islands. An island whose ancestor (up the DOM tree) is
133
+ // also a `<pyreon-island>` would, on hydration, be torn out and replaced
134
+ // when the outer island hydrates — losing its hydrate strategy entirely.
135
+ // Mark it as errored, log, and skip rather than silently break.
136
+ if (el.parentElement?.closest('pyreon-island')) {
137
+ console.error(
138
+ `[Pyreon] island "${componentId}" is nested inside another <pyreon-island>. ` +
139
+ `Nested islands are not supported — the outer island's hydrateRoot replaces ` +
140
+ `the inner element before its loader runs. Move the inner island out of the ` +
141
+ `outer island's tree, or fold them into a single component.`,
142
+ )
143
+ el.setAttribute('data-island-error', 'nested')
144
+ if (__DEV__) _countSink.__pyreon_count__?.('island.skipped.nested')
145
+ continue
146
+ }
147
+
148
+ const strategy = (el.getAttribute('data-hydrate') ?? 'load') as HydrationStrategy
149
+
150
+ // `hydrate: 'never'` deliberately ships zero client JS for the island —
151
+ // no loader is registered because the loader's whole purpose is to be
152
+ // imported. Skip the missing-loader warning for never-strategy islands;
153
+ // any other strategy without a loader IS a real misconfiguration.
154
+ if (strategy === 'never') {
155
+ if (__DEV__) _countSink.__pyreon_count__?.('island.skipped.never')
156
+ continue
157
+ }
158
+
115
159
  const loader = registry[componentId]
116
160
  if (!loader) {
117
161
  console.warn(`No loader registered for island "${componentId}"`)
162
+ el.setAttribute('data-island-error', 'no-loader')
163
+ if (__DEV__) _countSink.__pyreon_count__?.('island.skipped.no-loader')
118
164
  continue
119
165
  }
120
166
 
121
- const strategy = (el.getAttribute('data-hydrate') ?? 'load') as HydrationStrategy
122
167
  const propsJson = el.getAttribute('data-props') ?? '{}'
123
168
 
169
+ // Prefetch (if requested) primes the module cache before the hydration
170
+ // trigger fires. Independent of hydration scheduling — the same loader
171
+ // promise is reused, so a `visible` island with `prefetch: 'idle'` will
172
+ // hit a warm cache when the IntersectionObserver finally fires.
173
+ const prefetch = (el.getAttribute('data-prefetch') ?? 'none') as PrefetchStrategy
174
+ const prefetchCleanup = schedulePrefetch(el as HTMLElement, loader, prefetch)
175
+ if (prefetchCleanup) cleanups.push(prefetchCleanup)
176
+
177
+ if (__DEV__) _countSink.__pyreon_count__?.('island.scheduled')
124
178
  const cleanup = scheduleHydration(el as HTMLElement, loader, propsJson, strategy)
125
179
  if (cleanup) cleanups.push(cleanup)
126
180
  }
@@ -130,16 +184,127 @@ export function hydrateIslands(registry: Record<string, IslandLoader>): () => vo
130
184
  }
131
185
  }
132
186
 
187
+ function schedulePrefetch(
188
+ el: HTMLElement,
189
+ loader: IslandLoader,
190
+ prefetch: PrefetchStrategy,
191
+ ): (() => void) | null {
192
+ if (prefetch === 'none') return null
193
+ /* v8 ignore next */
194
+ if (typeof window === 'undefined') return null
195
+ let cancelled = false
196
+ // Fire and forget — we don't await; the dynamic import warms the module
197
+ // cache and the hydration path will await its OWN loader() call (which
198
+ // resolves to the same module via JS's import-promise dedup).
199
+ const prime = () => {
200
+ if (cancelled) return
201
+ if (__DEV__) _countSink.__pyreon_count__?.('island.prefetch')
202
+ loader().catch(() => {
203
+ // Silent — hydration will surface the failure with its own error path.
204
+ // Prefetch is a hint, not a contract.
205
+ })
206
+ }
207
+
208
+ if (prefetch === 'idle') {
209
+ if ('requestIdleCallback' in window) {
210
+ const id = requestIdleCallback(prime)
211
+ return () => {
212
+ cancelled = true
213
+ cancelIdleCallback(id)
214
+ }
215
+ }
216
+ const id = setTimeout(prime, 200)
217
+ return () => {
218
+ cancelled = true
219
+ clearTimeout(id)
220
+ }
221
+ }
222
+
223
+ // 'visible' — fetch ~200px before the island enters the viewport
224
+ if (!('IntersectionObserver' in window)) {
225
+ prime()
226
+ return null
227
+ }
228
+ const observer = new IntersectionObserver(
229
+ (entries) => {
230
+ for (const entry of entries) {
231
+ if (entry.isIntersecting) {
232
+ observer.disconnect()
233
+ prime()
234
+ return
235
+ }
236
+ }
237
+ },
238
+ { rootMargin: '200px' },
239
+ )
240
+ observer.observe(el)
241
+ return () => {
242
+ cancelled = true
243
+ observer.disconnect()
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Auto-discovered island registry shape — emitted by `@pyreon/vite-plugin`
249
+ * as `virtual:pyreon/islands-registry`. The user passes the imported module
250
+ * to `hydrateIslandsAuto()`.
251
+ */
252
+ export interface AutoIslandRegistry {
253
+ readonly __pyreonIslandRegistry: Record<string, IslandLoader>
254
+ readonly __pyreonIslandsEnabled: boolean
255
+ }
256
+
257
+ /**
258
+ * Hydrate all `<pyreon-island>` elements using a registry auto-generated by
259
+ * `@pyreon/vite-plugin` (`pyreon({ islands: true })` is the default).
260
+ *
261
+ * Eliminates the manual sync between `island()` declarations in source and
262
+ * the client-side `hydrateIslands({ ... })` call — typo / forgotten entry /
263
+ * registry drift is the #1 author foot-gun for islands.
264
+ *
265
+ * The auto-registry omits `hydrate: 'never'` islands by design; their
266
+ * components stay out of the client bundle entirely. Other strategies
267
+ * resolve via the same dynamic-import paths their `island()` declaration
268
+ * specified.
269
+ *
270
+ * The user passes the virtual-module result. We don't import it inside
271
+ * `@pyreon/server/client` because Rolldown's static-import analysis runs
272
+ * before plugin resolveId hooks for workspace sources, and would fail to
273
+ * resolve the virtual specifier. Importing in the user's entry-client (where
274
+ * the plugin's resolveId fires natively) is the clean shape.
275
+ *
276
+ * @example
277
+ * // src/entry-client.ts
278
+ * import { hydrateIslandsAuto } from '@pyreon/server/client'
279
+ * import * as registry from 'virtual:pyreon/islands-registry'
280
+ * hydrateIslandsAuto(registry)
281
+ */
282
+ export function hydrateIslandsAuto(registry: AutoIslandRegistry): () => void {
283
+ /* v8 ignore next */
284
+ if (typeof document === 'undefined') return () => {}
285
+ if (!registry.__pyreonIslandsEnabled) {
286
+ throw new Error(
287
+ `[Pyreon] hydrateIslandsAuto() requires \`pyreon({ islands: true })\` ` +
288
+ `in vite.config.ts (the default). The plugin emitted a stub registry ` +
289
+ `because islands support was explicitly disabled. Either re-enable ` +
290
+ `islands in the plugin, or use the manual hydrateIslands({ ... }) form.`,
291
+ )
292
+ }
293
+ return hydrateIslands(registry.__pyreonIslandRegistry)
294
+ }
295
+
133
296
  function scheduleHydration(
134
297
  el: HTMLElement,
135
298
  loader: IslandLoader,
136
299
  propsJson: string,
137
300
  strategy: HydrationStrategy,
138
301
  ): (() => void) | null {
302
+ /* v8 ignore next */
139
303
  if (typeof window === 'undefined') return null
140
304
  let cancelled = false
141
- const hydrate = () => {
142
- if (!cancelled) hydrateIsland(el, loader, propsJson)
305
+ const hydrate = (): Promise<void> => {
306
+ if (cancelled) return Promise.resolve()
307
+ return hydrateIsland(el, loader, propsJson)
143
308
  }
144
309
 
145
310
  switch (strategy) {
@@ -165,8 +330,15 @@ function scheduleHydration(
165
330
  case 'visible':
166
331
  return observeVisibility(el, hydrate)
167
332
 
168
- case 'never':
169
- return null
333
+ // `case 'never'` is dead here — `hydrateIslands` short-circuits before
334
+ // calling `scheduleHydration` when strategy === 'never' (the whole point
335
+ // of the strategy is shipping zero client JS). Removing the case keeps
336
+ // the branch surface tight; if a future caller invokes scheduleHydration
337
+ // directly with 'never', the default fallback (immediate hydrate) is the
338
+ // wrong shape — gate it at the call site, not here.
339
+
340
+ case 'interaction':
341
+ return scheduleInteractionHydration(el, hydrate, DEFAULT_INTERACTION_EVENTS)
170
342
 
171
343
  default:
172
344
  // media(query)
@@ -189,11 +361,162 @@ function scheduleHydration(
189
361
  mql.removeEventListener('change', onChange)
190
362
  }
191
363
  }
364
+ // interaction(<events>) — comma-separated event names
365
+ if (strategy.startsWith('interaction(')) {
366
+ const eventsStr = strategy.slice(12, -1).trim()
367
+ const events = eventsStr
368
+ ? eventsStr.split(',').map((s) => s.trim()).filter(Boolean)
369
+ : DEFAULT_INTERACTION_EVENTS
370
+ return scheduleInteractionHydration(el, hydrate, events)
371
+ }
192
372
  hydrate()
193
373
  return null
194
374
  }
195
375
  }
196
376
 
377
+ /**
378
+ * Default events for the `interaction` strategy. Picked to cover the common
379
+ * "user reaches for it" surface: keyboard (`focus`), mouse (`pointerenter`,
380
+ * `click`), and touch (`touchstart`). First matching event triggers hydrate
381
+ * + removes ALL listeners (one-shot).
382
+ */
383
+ const DEFAULT_INTERACTION_EVENTS: readonly string[] = [
384
+ 'focus',
385
+ 'click',
386
+ 'pointerenter',
387
+ 'touchstart',
388
+ ]
389
+
390
+ function scheduleInteractionHydration(
391
+ el: HTMLElement,
392
+ hydrate: () => Promise<void>,
393
+ events: readonly string[],
394
+ ): () => void {
395
+ let hydrationStarted = false
396
+ let hydrated = false
397
+ // Holds replay info if a click came in during in-flight hydration —
398
+ // we replay the click on the equivalent post-hydration element so the
399
+ // user's first click both wakes the island AND fires the live handler.
400
+ //
401
+ // Hydration may REPLACE DOM nodes (mismatch fallback, or even successful
402
+ // hydrate-as-mount on some shapes). The original `event.target` reference
403
+ // can therefore be detached after hydration completes. To survive this,
404
+ // we capture an identifying "replay path" — preferring `data-testid` (a
405
+ // stable, semantic identifier) and falling back to a tag-based child
406
+ // index walk relative to `el`. After hydration we re-query the live tree.
407
+ let replayPath: ReplayPath | null = null
408
+ // Stamp a "scheduled" marker for tests / devtools introspection.
409
+ el.setAttribute('data-island-state', 'awaiting-interaction')
410
+
411
+ const startHydration = () => {
412
+ if (hydrationStarted) return
413
+ hydrationStarted = true
414
+ el.setAttribute('data-island-state', 'hydrating')
415
+ void hydrate().then(() => {
416
+ hydrated = true
417
+ el.removeAttribute('data-island-state')
418
+ for (const ev of events) {
419
+ el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS)
420
+ }
421
+ if (!replayPath) return
422
+ const liveTarget = resolveReplayPath(el, replayPath)
423
+ if (liveTarget && liveTarget.isConnected) {
424
+ liveTarget.dispatchEvent(
425
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
426
+ )
427
+ }
428
+ })
429
+ }
430
+
431
+ const dispatch = (event: Event) => {
432
+ // After hydration, listeners are removed in the `then` above —
433
+ // this branch is defensive only.
434
+ if (hydrated) return
435
+
436
+ if (event.type === 'click') {
437
+ // Stop the click — the SSR DOM has no live click handler bound yet,
438
+ // so propagating it does nothing useful. Capture the click target
439
+ // for replay after hydration. Works whether or not hydration was
440
+ // already started by a previous non-click event — the click always
441
+ // replays as the user-intended action.
442
+ event.stopImmediatePropagation()
443
+ event.preventDefault()
444
+ const target = event.target as HTMLElement | null
445
+ if (target) replayPath = captureReplayPath(el, target)
446
+ }
447
+ startHydration()
448
+ }
449
+
450
+ // `passive: false` because the click handler may need preventDefault on
451
+ // the original event in the replay path. `capture: true` so we run
452
+ // BEFORE the live event-delegation handler that hydrateRoot installs
453
+ // on the container (otherwise the click would already have propagated
454
+ // past us by the time we react to non-click events firing first).
455
+ for (const ev of events) {
456
+ el.addEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS)
457
+ }
458
+ return () => {
459
+ if (hydrated) return
460
+ el.removeAttribute('data-island-state')
461
+ for (const ev of events) {
462
+ el.removeEventListener(ev, dispatch, INTERACTION_LISTENER_OPTS)
463
+ }
464
+ }
465
+ }
466
+
467
+ const INTERACTION_LISTENER_OPTS: AddEventListenerOptions = {
468
+ passive: false,
469
+ capture: true,
470
+ }
471
+
472
+ /**
473
+ * A locator path for replaying a click after hydration MAY have replaced the
474
+ * original DOM node. Two strategies:
475
+ * - `testid`: re-query the live tree by `[data-testid="..."]`. Stable,
476
+ * semantic, survives DOM swap.
477
+ * - `path`: a tag-name + child-index walk from the island root. Fallback
478
+ * for elements without a test id. Less stable (assumes the live tree
479
+ * mirrors the SSR tree's shape) but covers the no-testid case.
480
+ */
481
+ type ReplayPath =
482
+ | { kind: 'testid'; value: string }
483
+ | { kind: 'path'; steps: { tag: string; index: number }[] }
484
+
485
+ function captureReplayPath(el: Element, target: Element): ReplayPath | null {
486
+ const testid = target.getAttribute?.('data-testid')
487
+ if (testid) return { kind: 'testid', value: testid }
488
+ // Walk up from target to el, collecting (tag, child-index) at each step.
489
+ // `node` is non-null on every iteration because we early-return when
490
+ // `parent` is null (the only path that could leave node nullable). The
491
+ // `Element | null` type is preserved for the post-loop `node === el` check
492
+ // so the path-not-found case still returns null cleanly.
493
+ const steps: { tag: string; index: number }[] = []
494
+ let node: Element | null = target
495
+ while (node !== el) {
496
+ const parent: Element | null = node.parentElement
497
+ if (!parent) return null
498
+ const siblings = Array.from(parent.children)
499
+ const index = siblings.indexOf(node)
500
+ if (index < 0) return null
501
+ steps.unshift({ tag: node.tagName, index })
502
+ node = parent
503
+ }
504
+ return { kind: 'path', steps }
505
+ }
506
+
507
+ function resolveReplayPath(el: Element, path: ReplayPath): HTMLElement | null {
508
+ if (path.kind === 'testid') {
509
+ return el.querySelector<HTMLElement>(`[data-testid="${path.value}"]`)
510
+ }
511
+ let node: Element | null = el
512
+ for (const { tag, index } of path.steps) {
513
+ const child: Element | undefined = node?.children[index]
514
+ if (!child || child.tagName !== tag) return null
515
+ node = child
516
+ }
517
+ return node as HTMLElement | null
518
+ }
519
+
197
520
  async function hydrateIsland(
198
521
  el: HTMLElement,
199
522
  loader: IslandLoader,
@@ -209,18 +532,24 @@ async function hydrateIsland(
209
532
  }
210
533
  } catch (parseErr) {
211
534
  console.error(`Invalid island props JSON for "${name}"`, parseErr)
535
+ el.setAttribute('data-island-error', 'invalid-props')
536
+ if (__DEV__) _countSink.__pyreon_count__?.('island.error')
212
537
  return
213
538
  }
214
539
 
215
540
  const mod = await loader()
216
541
  const Comp = typeof mod === 'function' ? mod : mod.default
217
542
  hydrateRoot(el, h(Comp, props))
543
+ if (__DEV__) _countSink.__pyreon_count__?.('island.hydrated')
218
544
  } catch (err) {
219
545
  console.error(`Failed to hydrate island "${name}"`, err)
546
+ el.setAttribute('data-island-error', 'hydration-failed')
547
+ if (__DEV__) _countSink.__pyreon_count__?.('island.error')
220
548
  }
221
549
  }
222
550
 
223
551
  function observeVisibility(el: HTMLElement, callback: () => void): (() => void) | null {
552
+ /* v8 ignore next */
224
553
  if (typeof window === 'undefined') return null
225
554
  if (!('IntersectionObserver' in window)) {
226
555
  callback()
package/src/handler.ts CHANGED
@@ -160,7 +160,16 @@ export function createHandler(options: HandlerOptions): (req: Request) => Promis
160
160
  const headWithStyles = styleTag ? `${styleTag}\n${head}` : head
161
161
  const fullHtml = processCompiledTemplate(compiled, { head: headWithStyles, app: appHtml, scripts })
162
162
 
163
- return new Response(fullHtml, { status: 200, headers: ctx.headers })
163
+ // M1.2 Status 404 when the matched chain resolved via the
164
+ // `notFoundComponent` fallback (PR L5). The router's
165
+ // `resolveRoute` sets `isNotFound: true` when no leaf matched
166
+ // and a parent layout's `notFoundComponent` was used as a
167
+ // synthetic leaf. Reading the flag after render lets the
168
+ // handler emit a real HTTP 404 while still serving the
169
+ // chrome-wrapped 404 HTML.
170
+ const resolved = router.currentRoute() as { isNotFound?: boolean }
171
+ const status = resolved?.isNotFound === true ? 404 : 200
172
+ return new Response(fullHtml, { status, headers: ctx.headers })
164
173
  } catch (err) {
165
174
  // `redirect()` thrown from a loader — convert to a real HTTP redirect
166
175
  // before the SSR error path runs. Done inside the runWithRequestContext
@@ -226,12 +235,18 @@ async function renderStreamResponse(
226
235
 
227
236
  push(shellTail)
228
237
  } catch (err) {
238
+ // Defensive: catastrophic stream-level failure (rare; the SSR pipeline
239
+ // emits its own error markup for component-level errors). Status code
240
+ // is already 200 by the time we get here so we can only emit an
241
+ // inline error script and close the body. Branch is intentionally
242
+ // hard to exercise from tests without mocking `reader.read()`.
243
+ /* v8 ignore start */
229
244
  if (__DEV__) {
230
245
  console.error('[Pyreon Server] Stream render failed:', err)
231
246
  }
232
- // Emit an inline error indicator — status code is already sent (200)
233
247
  push(`<script>console.error("[pyreon/server] Stream render failed")</script>`)
234
248
  push(shellTail)
249
+ /* v8 ignore stop */
235
250
  } finally {
236
251
  controller.close()
237
252
  }
package/src/html.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * <!--pyreon-app--> — replaced with rendered application HTML
7
7
  * <!--pyreon-scripts--> — replaced with client entry script + inline loader data
8
8
  */
9
+ import { stringifyLoaderData } from '@pyreon/router'
9
10
 
10
11
  export const DEFAULT_TEMPLATE = `<!DOCTYPE html>
11
12
  <html lang="en">
@@ -78,8 +79,10 @@ export function buildScripts(
78
79
  const parts: string[] = []
79
80
 
80
81
  if (loaderData && Object.keys(loaderData).length > 0) {
81
- // Escape </script> inside JSON to prevent premature tag close
82
- const json = JSON.stringify(loaderData).replace(/<\//g, '<\\/')
82
+ // M2.2 safe serializer: strips function/symbol values silently,
83
+ // throws a Pyreon-prefixed error on circular refs naming the offending
84
+ // key, and escapes </script> for safe inline embedding.
85
+ const json = stringifyLoaderData(loaderData)
83
86
  parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}</script>`)
84
87
  }
85
88
 
@@ -99,7 +102,8 @@ export function buildScriptsFast(
99
102
  loaderData: Record<string, unknown> | null,
100
103
  ): string {
101
104
  if (loaderData && Object.keys(loaderData).length > 0) {
102
- const json = JSON.stringify(loaderData).replace(/<\//g, '<\\/')
105
+ // M2.2 safe serializer (same contract as `buildScripts`).
106
+ const json = stringifyLoaderData(loaderData)
103
107
  return `<script>window.__PYREON_LOADER_DATA__=${json}</script>\n ${clientEntryTag}`
104
108
  }
105
109
  return clientEntryTag