jq79 0.5.6 → 0.5.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
5
5
  "keywords": [
6
6
  "reactive",
package/src/jq79.ts CHANGED
@@ -128,34 +128,23 @@ let pendingScripts = 0
128
128
  let flushScheduled = false
129
129
 
130
130
  // everything else an expression can throw - overwhelmingly a member access on
131
- // an undefined value, `{{ game.is.loaded }}` over a game with no `is`. There is
132
- // no pure question to ask about it the way there is for a missing name: `in`
133
- // answers "declared anywhere?" without evaluating, and nothing answers "would
134
- // this still throw?" without running the expression again, which is exactly
135
- // what mustn't happen (`@click="count++ + missing"` increments twice).
131
+ // an undefined value, `{{ game.is.loaded }}` over a game with no `is`. Unlike a
132
+ // missing name it needs no deferral and gets none: the engine already caught a
133
+ // real exception and wrote the message, so there is nothing left to decide and
134
+ // it is reported where it throws, as an error rather than a warning.
136
135
  //
137
- // What is pure is watching the evaluations that were going to happen anyway. An
138
- // expression whose value is merely late is re-evaluated when the value arrives -
139
- // the reads that did land registered as dependencies, so the write wakes the
140
- // effect - and it succeeds then. So transient means "succeeds later", and the
141
- // re-check is to clear the entry on the next successful evaluation and report
142
- // only what is still queued once the page has gone quiet.
136
+ // That means a value still on its way is reported too - `{{ user.name }}` over
137
+ // a user that a fetch will fill renders empty and says so, once. It is a
138
+ // deliberate trade against the silence it replaces, which hid a render that was
139
+ // actively wrong (a thrown `:disabled` is falsy, so the button rendered
140
+ // *enabled*). The fix is the one the message names: `user?.name`, or `:if`.
143
141
  //
144
- // Quiet is a timer rather than the microtask the other queue flushes on,
145
- // because a value from fetch() arrives many tasks later and clearing on success
146
- // exists to let it. A second covers that and still lands the warning inside the
147
- // page load. It is a guess, and the cost is a slower fetch warning about correct
148
- // code with nothing to take it back - accepted because the silence it replaces
149
- // hid a render that was actively wrong
150
- const FAILED_EXPR_DELAY = 1000
151
-
152
- // keyed by expression text alone, not `message|expr`: the expression is what the
153
- // author edits, and one broken path throwing two messages is still one bug. The
154
- // entries hold no scope, only strings, so this queue cannot retain a live
155
- // component the way pendingReports can
156
- const failedExprs = new Map<string, string>()
142
+ // Deduped by expression text alone. A `:each` over 1000 rows throws 1000 times
143
+ // per render and the Set is what keeps that to one line; keying finer - by
144
+ // message too, as the missing-name queue does - would let one broken path
145
+ // report once per distinct message. There is no queue behind it because there
146
+ // is nothing to re-check, and so nothing that could retain a live scope
157
147
  const reportedFailedExprs = new Set<string>()
158
- let failedFlushTimer: ReturnType<typeof setTimeout> | undefined
159
148
 
160
149
  const flushExprReports = () => {
161
150
  flushScheduled = false
@@ -179,25 +168,6 @@ const scheduleExprReportFlush = () => {
179
168
  queueMicrotask(flushExprReports)
180
169
  }
181
170
 
182
- const flushFailedExprs = () => {
183
- failedFlushTimer = undefined
184
- if (pendingScripts > 0) return // a script started meanwhile; its release re-schedules
185
- failedExprs.forEach((message, expr) => {
186
- reportedFailedExprs.add(expr)
187
- console.warn(
188
- `jq79: ${message} - evaluating "${expr}". It rendered as nothing and was ` +
189
- `still failing a moment later, so the value is probably never coming: check ` +
190
- `the path. If it does arrive asynchronously, guard it - "a?.b", or :if on the element.`
191
- )
192
- })
193
- failedExprs.clear()
194
- }
195
-
196
- const scheduleFailedExprFlush = () => {
197
- if (failedFlushTimer !== undefined || pendingScripts > 0 || !failedExprs.size) return
198
- failedFlushTimer = setTimeout(flushFailedExprs, FAILED_EXPR_DELAY)
199
- }
200
-
201
171
  // scripts run before the template renders, so the counter is already up when
202
172
  // the first evaluation fails. Both script modes settle through a promise;
203
173
  // the factory's has to cover the merge, not just the module body
@@ -206,16 +176,22 @@ const trackScript = (settled: Promise<unknown>) => {
206
176
  const release = () => {
207
177
  pendingScripts--
208
178
  scheduleExprReportFlush()
209
- scheduleFailedExprFlush()
210
179
  }
211
180
  settled.then(release, release)
212
181
  }
213
182
 
183
+ // console.error, not warn: a name that resolves nowhere is a warning because
184
+ // the runtime can only say the name is absent, while this one caught a real
185
+ // exception - it has a message the engine wrote, and the expression rendered as
186
+ // nothing instead of doing what it says
214
187
  const reportFailedExpr = (expr: string, error: unknown) => {
215
- if (reportedFailedExprs.has(expr) || failedExprs.has(expr)) return
216
- if (failedExprs.size >= MAX_PENDING_REPORTS) return
217
- failedExprs.set(expr, (error as Error)?.message || String(error))
218
- scheduleFailedExprFlush()
188
+ if (reportedFailedExprs.has(expr)) return
189
+ reportedFailedExprs.add(expr)
190
+ const message = (error as Error)?.message || String(error)
191
+ console.error(
192
+ `jq79: ${message} - evaluating "${expr}". The expression rendered as nothing. ` +
193
+ `If the value arrives later, guard it - "a?.b", or :if on the element.`
194
+ )
219
195
  }
220
196
 
221
197
  const reportExprError = (expr: string, scope: Record<string, any>, error: unknown) => {
@@ -241,12 +217,7 @@ const runExpr = (expr: string, scope: Record<string, any>, extras?: Record<strin
241
217
 
242
218
  const evalExpr = (expr: string, scope: Record<string, any>, extras?: Record<string, any>): any => {
243
219
  try {
244
- const value = runExpr(expr, scope, extras)
245
- // the re-check for the failed-expression queue: this run is the proof that
246
- // the earlier throw was a value arriving late. Guarded on size so the
247
- // normal page - where nothing has ever thrown - pays one comparison
248
- if (failedExprs.size) failedExprs.delete(expr)
249
- return value
220
+ return runExpr(expr, scope, extras)
250
221
  } catch (error) {
251
222
  reportExprError(expr, scope, error)
252
223
  return undefined
@@ -1835,6 +1806,11 @@ const resolveSpecifier = (spec: string, filename: string | undefined): string =>
1835
1806
  // or devtools shows only one of them)
1836
1807
  type ScriptLocation = { filename?: string; index?: number }
1837
1808
 
1809
+ // what running a script tells its caller: the promise it settles through, and
1810
+ // whether it already finished on this stack. `sync` is the fast path the render
1811
+ // gate is built on - see runSetupScript
1812
+ type ScriptRun = { settled: Promise<unknown>; sync: boolean }
1813
+
1838
1814
  // nothing to name an inline component's scripts after, so they stay anonymous
1839
1815
  const sourceUrlComment = (filename: string | undefined, index: number): string =>
1840
1816
  filename ? `\n//# sourceURL=${filename}?jq79-script=${index}` : ""
@@ -1880,23 +1856,36 @@ const releaseStyle = (content: string) => {
1880
1856
  // normally. get/set are deliberately not trapped: they default-forward to
1881
1857
  // `scope` (the reactive proxy), preserving tracking and notify.
1882
1858
  // The body is wrapped in an async IIFE so top-level `await` works: everything
1883
- // up to the first await runs synchronously (before the template renders), and
1884
- // later assignments update the DOM reactively when they happen
1885
- const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}) => {
1859
+ // up to the first await runs synchronously, and later assignments update the
1860
+ // DOM reactively when they happen.
1861
+ //
1862
+ // Returns whether the body ran to completion synchronously, plus the promise it
1863
+ // settles through. renderWith needs the *synchronous* answer - a script that
1864
+ // finished in this turn cannot hold anything up, so the template can render on
1865
+ // this stack exactly as it always has (see the render gate). Asking the promise
1866
+ // instead would defer every render by a microtask, including the overwhelmingly
1867
+ // common case of a script with no await in it at all.
1868
+ //
1869
+ // The flag is set on the code's last line, after the `with` block rather than
1870
+ // inside it, so the scope proxy never sees the name - and appended, so the
1871
+ // author's line numbers (which sourceUrlComment maps for devtools) don't shift
1872
+ const runSetupScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}): ScriptRun => {
1886
1873
  // instanceHelpers are per-component-instance additions (e.g. $emit, which
1887
1874
  // is bound to this instance's DOM position)
1888
1875
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
1889
1876
  const scriptScope = new Proxy(scope, {
1890
1877
  has: (target, key) =>
1891
- key !== "$__effect" && key !== "$__import" &&
1878
+ key !== "$__effect" && key !== "$__import" && key !== "$__state" &&
1892
1879
  (Reflect.has(target, key) || !(key in globalThis) && !(key in helpers)),
1893
1880
  })
1881
+ const state: { done?: boolean } = {}
1894
1882
  const result: Promise<void> = new Function(
1895
- "$scope", "$__effect", "$__import", ...Object.keys(helpers),
1896
- `return (async () => { with ($scope) { ${code} } })()${sourceUrlComment(at.filename, at.index ?? 0)}`
1897
- )(scriptScope, effect, importer, ...Object.values(helpers))
1883
+ "$scope", "$__effect", "$__import", "$__state", ...Object.keys(helpers),
1884
+ `return (async () => { with ($scope) { ${code} }\n;$__state.done = true })()${sourceUrlComment(at.filename, at.index ?? 0)}`
1885
+ )(scriptScope, effect, importer, state, ...Object.values(helpers))
1898
1886
  result.catch(error => console.error("jq79: error in :setup script", error))
1899
1887
  trackScript(result)
1888
+ return { settled: result, sync: state.done === true }
1900
1889
  }
1901
1890
 
1902
1891
  // puts a component's declared props on the store, before any script runs and
@@ -2071,7 +2060,7 @@ const interopDefault = (mod: any) => (mod && mod.default !== undefined ? mod.def
2071
2060
  // synchronous body invokes the factory before the first render, matching
2072
2061
  // setup-script timing; bodies with top-level await (static imports included)
2073
2062
  // resolve later and the template updates reactively
2074
- const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}) => {
2063
+ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run: () => void) => void, instanceHelpers: Record<string, any> = {}, importer: (url: string) => Promise<any> = importResource, at: ScriptLocation = {}): ScriptRun => {
2075
2064
  const helpers = { ...SETUP_HELPERS, ...instanceHelpers }
2076
2065
  const $__exports: { default?: (props: Record<string, any>, ctx: Record<string, any>) => any; done?: boolean } = {}
2077
2066
  const result: Promise<void> = new Function(
@@ -2112,8 +2101,14 @@ const runFactoryScript = (code: string, scope: Record<string, any>, effect: (run
2112
2101
  // tracked through the merge, not just the module body: a factory's names
2113
2102
  // reach the store in `merge`, and a template expression that reads one before
2114
2103
  // then is not an authoring mistake (see reportExprError)
2115
- trackScript(result.then(invoke, logError))
2104
+ const settled = result.then(invoke, logError)
2105
+ trackScript(settled)
2116
2106
  if ($__exports.done) invoke() // fully-sync body: factory runs before first render
2107
+ // sync only if the bindings are already on the store: a factory whose body
2108
+ // finished but whose *factory* returned a promise (an async factory, or one
2109
+ // that awaits $mounted()) still has names on the way, and the render gate
2110
+ // must treat it as pending rather than race its merge
2111
+ return { settled, sync: $__exports.done === true && merging === undefined }
2117
2112
  }
2118
2113
 
2119
2114
  // ---------------------------------------------------------------------------
@@ -2212,6 +2207,32 @@ export const enableHotReload = (): void => {
2212
2207
 
2213
2208
  type EmitListener = (event: CustomEvent, payload: any) => void
2214
2209
 
2210
+ // how long a first render may sit behind its scripts before the console says so
2211
+ const STUCK_RENDER_DELAY = 3000
2212
+
2213
+ // a script that neither returns nor calls $mounted() holds the template
2214
+ // forever, and the failure looks like nothing at all: no error, no markup, a
2215
+ // component indistinguishable from one nobody mounted. So the wait is loud
2216
+ // after a few seconds - and it keeps waiting, because rendering on a timer
2217
+ // would make the moment of the first render depend on the machine it runs on.
2218
+ //
2219
+ // Armed only on the deferred path, so a page of synchronous components creates
2220
+ // no timers at all
2221
+ const warnIfStuck = (component: Component79, gates: Promise<void>[]) => {
2222
+ const timer = setTimeout(() => {
2223
+ console.warn(
2224
+ `jq79: ${component.name ? `<${component.name}>` : "a component"}${component.filename ? ` (${component.filename})` : ""} ` +
2225
+ `has been waiting ${STUCK_RENDER_DELAY / 1000}s for a :setup script and has rendered nothing. ` +
2226
+ "The template waits until every script returns or calls $mounted() - add an " +
2227
+ "await $mounted() above the slow part to render first and fill in after."
2228
+ )
2229
+ }, STUCK_RENDER_DELAY)
2230
+ // unref where it exists (node/vitest): a pending timer must not be what keeps
2231
+ // a process alive. Browsers have no such notion and no such need
2232
+ ;(timer as any)?.unref?.()
2233
+ Promise.all(gates).then(() => clearTimeout(timer))
2234
+ }
2235
+
2215
2236
  const fetchComponent = async (url: string): Promise<Component79> => {
2216
2237
  const response = await fetch(url)
2217
2238
  if (!response.ok) throw new Error(`failed to fetch component from ${url}: ${response.status}`)
@@ -2282,6 +2303,11 @@ export class Component79 {
2282
2303
  private mountRoot: Element | ShadowRoot | DocumentFragment | null = null
2283
2304
  // settles the $mounted() promise handed to this render generation's scripts
2284
2305
  private resolveMounted: (() => void) | null = null
2306
+ // whether this generation's template has been built. A render held back by a
2307
+ // script (see the gate in renderWith) has markers but no nodes, and $mounted()
2308
+ // must not resolve on attach alone - a script awaiting it would wake to an
2309
+ // empty component and find nothing to query
2310
+ private renderDone = false
2285
2311
  // instance-level listeners for $emit events, registered with on(). Kept
2286
2312
  // outside the render generation so they survive re-render and destroy()
2287
2313
  private emitListeners = new Map<string, Set<EmitListener>>()
@@ -2339,7 +2365,6 @@ export class Component79 {
2339
2365
  reportedExprErrors.clear()
2340
2366
  pendingReports.clear()
2341
2367
  reportedFailedExprs.clear()
2342
- failedExprs.clear()
2343
2368
  const marker = this.startMarker
2344
2369
  const rendered = !!(marker && this.content)
2345
2370
 
@@ -2375,7 +2400,7 @@ export class Component79 {
2375
2400
  if (shadow) this.styleEls.forEach(el => parent.insertBefore(el, before))
2376
2401
  parent.insertBefore(this.content!, before)
2377
2402
  this.mountRoot = parent
2378
- this.resolveMounted?.()
2403
+ this.settleMounted()
2379
2404
  return true
2380
2405
  }
2381
2406
 
@@ -2483,16 +2508,18 @@ export class Component79 {
2483
2508
  return !event.defaultPrevented
2484
2509
  }
2485
2510
 
2486
- // `await $mounted()` suspends a setup script until mount() attaches the
2487
- // component, so code below it can querySelector its own DOM. Resumption
2488
- // is a microtask, so in the usual synchronous render().mount() flow the
2489
- // whole tree (nested components included) is in the document before the
2490
- // script continues. If this instance is never mounted, the promise stays
2491
- // pending and the script's tail never runs
2511
+ // `await $mounted()` suspends a setup script until the component is
2512
+ // rendered *and* attached, so code below it can querySelector its own DOM.
2513
+ // If this instance is never mounted, the promise stays pending and the
2514
+ // script's tail never runs.
2515
+ //
2516
+ // Calling it is also how a script releases the first render - see the gate
2517
+ // below - so the two halves of the contract are one call: "put me on the
2518
+ // page, and don't wait for the rest of me"
2492
2519
  let resolveMounted!: () => void
2493
2520
  const mounted = new Promise<void>(resolve => { resolveMounted = resolve })
2494
2521
  this.resolveMounted = resolveMounted
2495
- const $mounted = () => mounted
2522
+ this.renderDone = false
2496
2523
 
2497
2524
  // $self / $$self mirror $ / $$ but only search this instance's own
2498
2525
  // output: the sibling nodes between its markers. They work detached too
@@ -2563,7 +2590,31 @@ export class Component79 {
2563
2590
  // first line, so deferring doesn't shift the lines devtools reports for it
2564
2591
  const defer = (code: string) => `await $mounted();${code}`
2565
2592
 
2593
+ // what the first render is still waiting for. A script holds the template
2594
+ // back until it returns or calls $mounted() - whichever comes first - so
2595
+ // `let rows = await fetch(...)` renders once, with rows, instead of
2596
+ // rendering empty and filling in. `:mounted` is not a special case here: it
2597
+ // *is* a script that yields on line 0, which is what `defer` above writes.
2598
+ //
2599
+ // One gate per script, not one per instance: a script yielding must not
2600
+ // release the render on behalf of a sibling script that is still fetching
2601
+ const gates: Promise<void>[] = []
2602
+ let allSync = true
2603
+
2566
2604
  this.scripts.forEach((script, index) => {
2605
+ let resolveGate!: () => void
2606
+ gates.push(new Promise<void>(resolve => { resolveGate = resolve }))
2607
+ // whether this gate is already open on *this* stack, which is not the
2608
+ // same as the script having finished: a script that yields immediately
2609
+ // (`await $mounted()` on its first line, which is what `:mounted`
2610
+ // compiles to) never finishes synchronously but holds nothing up either.
2611
+ // Reading the promise instead would push every such render a microtask
2612
+ // later, for no one's benefit
2613
+ let open = false
2614
+ const release = () => { open = true; resolveGate() }
2615
+ // this script's own view of $mounted: the call releases its gate, the
2616
+ // promise it returns is the instance's (one mount, one resolution)
2617
+ const $mounted = () => { release(); return mounted }
2567
2618
  // the file's other components are passed as parameters of the compiled
2568
2619
  // script, not just left on the store's prototype: a factory script runs
2569
2620
  // as plain lexical JS with no `with`, so a bare `Row` in one would
@@ -2572,20 +2623,40 @@ export class Component79 {
2572
2623
  // parameter is what the name resolves to
2573
2624
  const instanceHelpers = { $mounted, $self, $$self, ...injected, ...siblingScope }
2574
2625
  const at: ScriptLocation = { filename: this.filename, index }
2626
+ const deferred = ":mounted" in script.attrs
2575
2627
  const factoryCode = transformFactoryScript(script.content)
2576
- if (factoryCode !== null) {
2577
- declareProps(store, parseFactoryProps(script.content))
2578
- const body = ":mounted" in script.attrs ? defer(factoryCode) : factoryCode
2579
- runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
2580
- return
2581
- }
2582
- const { vars, code } = transformSetupScript(script.content)
2583
- declareProps(store, setupSignature(script))
2584
- // pre-declare script vars on the store so `with` resolves assignments
2585
- // to them (and reads of them) through the reactive proxy
2586
- vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
2587
- const body = ":mounted" in script.attrs ? defer(code) : code
2588
- runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
2628
+ const run = ((): ScriptRun => {
2629
+ if (factoryCode !== null) {
2630
+ // a factory publishes its names by returning them, so one that yields
2631
+ // before it returns renders against a store where none of them exist.
2632
+ // In factory mode `:mounted` yields on line 0, which means *always* -
2633
+ // and unlike a setup script there is no way to put the useful half
2634
+ // above the yield. Awaiting $mounted() inside the factory does what
2635
+ // the author meant, and is what the message points at
2636
+ if (deferred) {
2637
+ console.warn(
2638
+ "jq79: :mounted on a factory script renders the template before the factory has returned, " +
2639
+ "so none of its bindings exist yet - await $mounted() inside the factory instead."
2640
+ )
2641
+ }
2642
+ declareProps(store, parseFactoryProps(script.content))
2643
+ const body = deferred ? defer(factoryCode) : factoryCode
2644
+ return runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
2645
+ }
2646
+ const { vars, code } = transformSetupScript(script.content)
2647
+ declareProps(store, setupSignature(script))
2648
+ // pre-declare script vars on the store so `with` resolves assignments
2649
+ // to them (and reads of them) through the reactive proxy
2650
+ vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
2651
+ const body = deferred ? defer(code) : code
2652
+ return runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
2653
+ })()
2654
+ // a script that threw has nothing left to contribute, so its rejection
2655
+ // releases the gate exactly as completion does - the error is already
2656
+ // reported by the runner, and holding the template hostage to it would
2657
+ // turn one broken script into a blank component
2658
+ run.settled.then(release, release)
2659
+ if (!run.sync && !open) allSync = false
2589
2660
  })
2590
2661
 
2591
2662
  const content = document.createDocumentFragment()
@@ -2600,8 +2671,36 @@ export class Component79 {
2600
2671
  ? injected[key]
2601
2672
  : Reflect.get(target, key, receiver),
2602
2673
  })
2603
- content.append(this.startMarker, renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
2674
+ // the markers go in either way, so render() returns something mountable
2675
+ // whether or not the template has been built yet: they are what detach()
2676
+ // collects between and what the deferred pass inserts before, exactly as
2677
+ // :if/:each anchors already work. That is what keeps render() and mount()
2678
+ // synchronous while the first render itself is allowed to wait
2679
+ content.append(this.startMarker, this.endMarker)
2604
2680
  this.content = content
2681
+ if (allSync) {
2682
+ // nothing is pending, so the template is built on this stack - the
2683
+ // ordinary case, and byte-for-byte the timing render() has always had.
2684
+ //
2685
+ // Written out rather than routed through the closure below on purpose: a
2686
+ // component that nests itself recurses through here, so one extra frame
2687
+ // per level is one fewer level before the stack gives out - enough, when
2688
+ // this was a shared `paint()`, to overflow *underneath* the depth guard
2689
+ // at MAX_NESTING_DEPTH and turn a named error back into a RangeError
2690
+ this.endMarker.parentNode!.insertBefore(renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
2691
+ this.renderDone = true
2692
+ this.settleMounted()
2693
+ } else {
2694
+ Promise.all(gates).then(() => {
2695
+ // destroy() nulls the markers and a re-render replaces them, so a gate
2696
+ // that opens after either one has nothing left to paint into
2697
+ if (marker !== this.startMarker) return
2698
+ this.endMarker!.parentNode!.insertBefore(renderNodes(this.template, templateScope, fx, shadow), this.endMarker!)
2699
+ this.renderDone = true
2700
+ this.settleMounted()
2701
+ })
2702
+ warnIfStuck(this, gates)
2703
+ }
2605
2704
 
2606
2705
  if (shadow) {
2607
2706
  this.styleEls = this.styles.map(style => {
@@ -2648,10 +2747,18 @@ export class Component79 {
2648
2747
  if (this.useShadow) this.styleEls.forEach(el => root.appendChild(el))
2649
2748
  root.appendChild(this.content!)
2650
2749
  this.mountRoot = root
2651
- this.resolveMounted?.()
2750
+ this.settleMounted()
2652
2751
  return this
2653
2752
  }
2654
2753
 
2754
+ // `await $mounted()` means "rendered and on the page", so it waits for both -
2755
+ // whichever lands last calls this. In the ordinary synchronous flow the render
2756
+ // is already done and this is the attach; for a component whose first render
2757
+ // a script held back, it is the other way round
2758
+ private settleMounted() {
2759
+ if (this.renderDone && this.mountRoot) this.resolveMounted?.()
2760
+ }
2761
+
2655
2762
  // detaches from the DOM while keeping all state; a later mount() re-attaches
2656
2763
  // with any updates that happened while detached already applied
2657
2764
  detach(): this {
@@ -2687,6 +2794,7 @@ export class Component79 {
2687
2794
  this.content = null
2688
2795
  this.startMarker = null
2689
2796
  this.endMarker = null
2797
+ this.renderDone = false
2690
2798
  this.data = null
2691
2799
  this.resolveMounted = null
2692
2800
  return this