@flareapp/svelte 2.6.0 → 2.8.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/README.md CHANGED
@@ -66,6 +66,119 @@ See the [JavaScript identifying-users docs](https://flareapp.io/docs/javascript/
66
66
  Full documentation on the error boundary, lifecycle callbacks, reset keys, custom boundary usage, and more is available
67
67
  at [flareapp.io/docs/svelte/general/installation](https://flareapp.io/docs/svelte/general/installation).
68
68
 
69
+ ## Component profiling
70
+
71
+ Records one span per component mount, nested under the active pageload or navigation trace, so you can
72
+ see which components dominate a page's render time. Requires tracing to be enabled (`enableTracing:
73
+ true`).
74
+
75
+ Turn it on in `svelte.config.js` with an allowlist:
76
+
77
+ ```js
78
+ import { withFlareConfig } from '@flareapp/svelte/config';
79
+ import adapter from '@sveltejs/adapter-node';
80
+
81
+ export default withFlareConfig(
82
+ { kit: { adapter: adapter() } },
83
+ { profileComponents: [/\+(page|layout)(@[^/]*)?$/, 'AddToCartButton'] },
84
+ );
85
+ ```
86
+
87
+ `profileComponents` accepts:
88
+
89
+ - **nothing** (the default): no components are profiled.
90
+ - **an array** of strings and regular expressions. Strings match exactly, regexes by `test()`.
91
+ - **`true`**: every component. This is a debugging aid, not a production setting. A real page will hit
92
+ the 1024 span cap and bury the spans you care about among icons and list items.
93
+
94
+ Profiling and component tracking are independent. You can run either on its own:
95
+
96
+ ```js
97
+ withFlareConfig(config, { componentTracking: false, profileComponents: [/\+page$/] });
98
+ ```
99
+
100
+ ### Preprocessor ordering
101
+
102
+ Both features are injected by a Svelte preprocessor, which `withFlareConfig` installs for you. It parses
103
+ each file with `svelte/compiler` to find the component's own instance script, which is the only reliable
104
+ way to tell that script apart from a `<script>` nested in your markup. So it has to be handed Svelte
105
+ syntax.
106
+
107
+ That only constrains you if you also run a markup preprocessor that converts another template language
108
+ (pug and the like) into Svelte. `withFlareConfig` puts the Flare preprocessor first, where it would see
109
+ the untransformed template, so in that setup wire it up yourself and place it after the one that
110
+ produces Svelte:
111
+
112
+ ```js
113
+ import { flarePreprocessor } from '@flareapp/svelte/config';
114
+
115
+ export default {
116
+ preprocess: [templateToSvelte(), flarePreprocessor({ profileComponents: [/\+page$/] })],
117
+ };
118
+ ```
119
+
120
+ Style blocks are not affected. `<style lang="scss">` and friends are handled regardless of ordering.
121
+
122
+ A file the preprocessor cannot parse is left exactly as it was, with a warning naming the file. You lose
123
+ that component's registration, never the build.
124
+
125
+ ### Component names
126
+
127
+ Names come from the filename, and route files carry their route directory so they stay distinguishable:
128
+
129
+ | File | Name |
130
+ | -------------------------------------- | -------------------- |
131
+ | `src/lib/ProductGallery.svelte` | `ProductGallery` |
132
+ | `src/routes/+page.svelte` | `+page` |
133
+ | `src/routes/product/[id]/+page.svelte` | `product/[id]/+page` |
134
+ | `src/routes/product/+layout.svelte` | `product/+layout` |
135
+
136
+ Without the route prefix every route in a SvelteKit app would be called `+page`, and the allowlist could
137
+ not target one of them. The allowlist matches the same string the span reports, so what you write is what
138
+ you see. Renaming or moving a file silently stops it being profiled.
139
+
140
+ Layout-breakout files (`+page@.svelte`, `+page@(app).svelte`, `+layout@.svelte`, and so on) keep their
141
+ `@` suffix in the span name, for example `foo/+page@(app)`.
142
+
143
+ ### What the tree shows
144
+
145
+ Only components you matched produce spans. A matched component nests under the nearest **matched**
146
+ ancestor, skipping anything unmatched in between, so the tree reflects your allowlist rather than the
147
+ real component tree.
148
+
149
+ ### Two shapes, both correct
150
+
151
+ A layout mounts once and does not re-mount when you navigate, so it records a span on the load where it
152
+ mounted and not on later navigations. Expect the full tree on a pageload:
153
+
154
+ ```
155
+ browser_pageload /product/[id] 312ms
156
+ └─ +layout 290ms
157
+ └─ product/[id]/+page 240ms
158
+ └─ AddToCartButton 12ms
159
+ ```
160
+
161
+ and a flatter one after a client-side navigation:
162
+
163
+ ```
164
+ browser_navigation /product/[other] 180ms
165
+ └─ product/[other]/+page 140ms
166
+ └─ AddToCartButton 11ms
167
+ ```
168
+
169
+ The missing layout span is expected, not a dropped span.
170
+
171
+ ### Waits in `{#await}` are not included
172
+
173
+ A component does not wait for a pending `{#await}` branch before it finishes mounting. Read a parent's
174
+ duration as the time to mount its own synchronous subtree, not as time-to-interactive. A component
175
+ rendered inside `{:then}` can therefore start after its parent has already ended.
176
+
177
+ ### No update spans
178
+
179
+ Only mounts are recorded. Svelte 5 disallows `beforeUpdate` and `afterUpdate` in runes mode, so there is
180
+ no reliable way to time an update from outside a component.
181
+
69
182
  ## Compatibility
70
183
 
71
184
  - Svelte 5.3+
@@ -87,8 +87,9 @@ function hasAncestor(node, ancestor) {
87
87
  let current = node.parent;
88
88
  const seen = new Set();
89
89
  while (current && !seen.has(current)) {
90
- if (current === ancestor)
90
+ if (current === ancestor) {
91
91
  return true;
92
+ }
92
93
  seen.add(current);
93
94
  current = current.parent;
94
95
  }
package/dist/config.d.ts CHANGED
@@ -1,13 +1,27 @@
1
+ import type { ProfileComponentsOption } from '@flareapp/core/util';
1
2
  import type { PreprocessorGroup } from 'svelte/compiler';
2
3
  import { type FlarePreprocessorOptions } from './preprocessor.js';
3
4
  interface SvelteConfig {
4
5
  preprocess?: PreprocessorGroup | PreprocessorGroup[];
6
+ kit?: {
7
+ files?: {
8
+ routes?: string;
9
+ };
10
+ [key: string]: unknown;
11
+ };
5
12
  [key: string]: unknown;
6
13
  }
7
14
  export interface WithFlareConfigOptions {
8
15
  componentTracking?: boolean;
16
+ /** Which components get a mount span. Matched against the route-aware profile name. */
17
+ profileComponents?: ProfileComponentsOption;
9
18
  exclude?: FlarePreprocessorOptions['exclude'];
10
19
  importSource?: string;
11
20
  }
21
+ /**
22
+ * Wraps a SvelteKit config with Flare's preprocessor, passing through `kit.files.routes` for
23
+ * route-aware profile names. Returns `config` unchanged if nothing was requested, or a Flare
24
+ * preprocessor is already installed.
25
+ */
12
26
  export declare function withFlareConfig(config: SvelteConfig, options?: WithFlareConfigOptions): SvelteConfig;
13
27
  export { flarePreprocessor, type FlarePreprocessorOptions } from './preprocessor.js';
package/dist/config.js CHANGED
@@ -1,14 +1,27 @@
1
1
  import { flarePreprocessor } from './preprocessor.js';
2
+ /**
3
+ * Wraps a SvelteKit config with Flare's preprocessor, passing through `kit.files.routes` for
4
+ * route-aware profile names. Returns `config` unchanged if nothing was requested, or a Flare
5
+ * preprocessor is already installed.
6
+ */
2
7
  export function withFlareConfig(config, options) {
3
- const { componentTracking = true, exclude, importSource } = options ?? {};
4
- if (!componentTracking) {
8
+ const { componentTracking = true, profileComponents = false, exclude, importSource } = options ?? {};
9
+ // An empty array profiles nothing, so treat it as off when deciding whether to install.
10
+ const profilingRequested = profileComponents === true || (Array.isArray(profileComponents) && profileComponents.length > 0);
11
+ if (!componentTracking && !profilingRequested) {
5
12
  return config;
6
13
  }
7
14
  const existing = normalizePreprocessors(config.preprocess);
8
15
  if (existing.some((p) => !!p.__flareId)) {
9
16
  return config;
10
17
  }
11
- const preprocessor = flarePreprocessor({ exclude, importSource });
18
+ const preprocessor = flarePreprocessor({
19
+ exclude,
20
+ importSource,
21
+ componentTracking,
22
+ profileComponents,
23
+ routesDir: config.kit?.files?.routes,
24
+ });
12
25
  preprocessor.__flareId = true;
13
26
  return {
14
27
  ...config,
@@ -16,10 +29,12 @@ export function withFlareConfig(config, options) {
16
29
  };
17
30
  }
18
31
  function normalizePreprocessors(preprocess) {
19
- if (!preprocess)
32
+ if (!preprocess) {
20
33
  return [];
21
- if (Array.isArray(preprocess))
34
+ }
35
+ if (Array.isArray(preprocess)) {
22
36
  return preprocess;
37
+ }
23
38
  return [preprocess];
24
39
  }
25
40
  export { flarePreprocessor } from './preprocessor.js';
@@ -1,7 +1,4 @@
1
+ import { toCustomContext } from '@flareapp/core';
1
2
  export function contextToAttributes(context) {
2
- return {
3
- 'context.custom': {
4
- svelte: context.svelte,
5
- },
6
- };
3
+ return toCustomContext('svelte', context.svelte);
7
4
  }
@@ -4,11 +4,11 @@ export function extractComponentInfo(frames, ancestor) {
4
4
  if (svelteFrames.length === 0) {
5
5
  return { componentName: null, componentHierarchy: [] };
6
6
  }
7
- // Try the preprocessor-based component tree first.
8
- // Walk each .svelte frame until we find one that was registered.
7
+ // Preprocessor-based component tree first: walk each .svelte frame until one was registered.
9
8
  for (const frame of svelteFrames) {
10
- if (!frame.fileName)
9
+ if (!frame.fileName) {
11
10
  continue;
11
+ }
12
12
  const treeHierarchy = lookupComponentTree(frame.fileName, ancestor);
13
13
  if (treeHierarchy.length > 0) {
14
14
  return {
@@ -1,3 +1,7 @@
1
1
  import type ErrorStackParser from 'error-stack-parser';
2
2
  import type { SvelteErrorOrigin } from './types.js';
3
+ /**
4
+ * Classify a parsed stack trace's error origin: 'event' (DOM handler), 'effect' (async side-effect),
5
+ * 'render' (component render phase), or 'unknown' (no frames or no recognizable pattern).
6
+ */
3
7
  export declare function getErrorOrigin(frames: ErrorStackParser.StackFrame[]): SvelteErrorOrigin;
@@ -1,16 +1,8 @@
1
- // Heuristic classification of where a Svelte error originated, based on stack frame inspection.
2
- //
3
- // Svelte's onMount/beforeUpdate/$effect callbacks, DOM event handlers, and render functions
4
- // all produce errors that look identical at the catch site (the error boundary sees a plain Error
5
- // either way). The only distinguishing signal is the stack trace: different origins leave
6
- // recognizable function names, file names, or DOM API calls in the frames.
7
- //
8
- // We check patterns in priority order: event > effect > render > unknown.
9
- // Priority matters because an event handler inside a Svelte component would match both
10
- // EVENT_PATTERNS and the .svelte filename check; we want 'event' to win.
11
- // Inline event handlers (onclick, onsubmit, ...) and DOM event API calls.
12
- // Browsers compile `onclick={handler}` to `.onclick = ...` or `addEventListener(...)`,
13
- // so these patterns catch both Svelte's compiled output and manual DOM calls.
1
+ // Origins are indistinguishable at the boundary catch site; the stack trace is the only signal.
2
+ // Checked in priority order event > effect > render > unknown, so an event handler inside a
3
+ // component wins over the .svelte filename check.
4
+ // Inline event handlers and DOM event API calls. Svelte compiles `onclick={handler}` to
5
+ // `.onclick = ...` or `addEventListener(...)`, so these match compiled output and manual DOM calls.
14
6
  const EVENT_PATTERNS = [
15
7
  /\.onclick\b/i,
16
8
  /\.onsubmit\b/i,
@@ -25,37 +17,33 @@ const EVENT_PATTERNS = [
25
17
  /\.ontouch/i,
26
18
  /addEventListener/,
27
19
  /dispatchEvent/,
20
+ // The rest match the native frame a browser inserts for the DOM call that invoked the listener
21
+ // (e.g. `HTMLButtonElement.onclick (native)`), not application code calling that API.
28
22
  /EventTarget\./,
29
23
  /HTMLElement\./,
30
24
  /HTMLButtonElement\./,
31
25
  /HTMLInputElement\./,
32
26
  /HTMLFormElement\./,
33
27
  ];
34
- // Async side-effects: $effect callbacks, onMount microtasks, promise continuations.
35
- // Svelte 5 schedules effects via queueMicrotask; promise chains and MutationObserver
36
- // callbacks are also async side-effects, not render-phase code.
28
+ // Async side-effects: $effect callbacks (Svelte 5 schedules via queueMicrotask), onMount
29
+ // microtasks, promise continuations, MutationObserver callbacks. Not render-phase code.
37
30
  const EFFECT_PATTERNS = [/queueMicrotask/, /Promise\.then/, /Promise\.catch/, /MutationObserver/];
38
- // Inspects a parsed stack trace and classifies the error origin into one of:
39
- // 'event' - error happened in a DOM event handler
40
- // 'effect' - error happened in an async side-effect ($effect, onMount, promise)
41
- // 'render' - error happened during Svelte component render (template/script top-level)
42
- // 'unknown' - no frames or no recognizable pattern
31
+ /**
32
+ * Classify a parsed stack trace's error origin: 'event' (DOM handler), 'effect' (async side-effect),
33
+ * 'render' (component render phase), or 'unknown' (no frames or no recognizable pattern).
34
+ */
43
35
  export function getErrorOrigin(frames) {
44
36
  if (frames.length === 0) {
45
37
  return 'unknown';
46
38
  }
47
- // Flatten each frame into a single searchable string so regex matching is straightforward.
48
39
  const frameStrings = frames.map((f) => `${f.functionName ?? ''} ${f.fileName ?? ''} ${f.source ?? ''}`);
49
- // Check event patterns first (highest priority).
50
40
  if (frameStrings.some((s) => EVENT_PATTERNS.some((p) => p.test(s)))) {
51
41
  return 'event';
52
42
  }
53
- // Then async effect patterns.
54
43
  if (frameStrings.some((s) => EFFECT_PATTERNS.some((p) => p.test(s)))) {
55
44
  return 'effect';
56
45
  }
57
- // If any frame originates from a .svelte file but didn't match event/effect,
58
- // the error most likely occurred during the synchronous render phase.
46
+ // A .svelte frame that matched neither event nor effect is most likely the synchronous render phase.
59
47
  const hasSvelteFrame = frames.some((f) => f.fileName?.includes('.svelte'));
60
48
  if (hasSvelteFrame) {
61
49
  return 'render';
@@ -1,3 +1,5 @@
1
1
  import type { Flare } from '@flareapp/js/browser';
2
+ /** Web path: full identity on the default singleton. Svelte's framework has no version. */
2
3
  export declare function registerSvelteSdkIdentity(flare: Flare): void;
4
+ /** Injected path: framework tag only, never sdkInfo. */
3
5
  export declare function tagSvelteFramework(flare: Flare): void;
package/dist/identify.js CHANGED
@@ -1,22 +1,16 @@
1
+ import { createIdentityTagger, FrameworkName } from '@flareapp/core';
1
2
  import { PACKAGE_VERSION } from './version.js';
2
- // Per-instance guards. A boolean cannot serve injection: with a singleton AND an
3
- // injected RendererFlare, each instance must be tagged independently.
4
- const sdkTagged = new WeakSet();
5
- const frameworkTagged = new WeakSet();
6
- // Web path: full identity on the default singleton (sdk + framework). Svelte's framework has no version.
3
+ const tagger = createIdentityTagger({
4
+ sdkName: '@flareapp/svelte',
5
+ sdkVersion: PACKAGE_VERSION,
6
+ frameworkName: FrameworkName.Svelte,
7
+ });
8
+ /** Web path: full identity on the default singleton. Svelte's framework has no version. */
7
9
  export function registerSvelteSdkIdentity(flare) {
8
- if (!sdkTagged.has(flare)) {
9
- sdkTagged.add(flare);
10
- flare.setSdkInfo({ name: '@flareapp/svelte', version: PACKAGE_VERSION });
11
- }
12
- tagSvelteFramework(flare);
10
+ tagger.registerSdkIdentity(flare);
11
+ tagger.tagFramework(flare, undefined);
13
12
  }
14
- // Injected path: framework tag ONLY. Never touch sdkInfo — that would clobber the
15
- // injected instance's own SDK name (e.g. @flareapp/electron).
13
+ /** Injected path: framework tag only, never sdkInfo. */
16
14
  export function tagSvelteFramework(flare) {
17
- if (frameworkTagged.has(flare)) {
18
- return;
19
- }
20
- frameworkTagged.add(flare);
21
- flare.setFramework({ name: 'Svelte' });
15
+ tagger.tagFramework(flare, undefined);
22
16
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { default as FlareErrorBoundary } from './FlareErrorBoundary.svelte';
2
2
  export { createFlareErrorHandler, type FlareErrorHandlerOptions } from './createFlareErrorHandler.js';
3
3
  export { __flareRegisterComponent, getComponentTreeContext } from './componentTree.js';
4
+ export { __flareProfileComponent } from './profileComponent.js';
4
5
  export { withFlareConfig, type WithFlareConfigOptions } from './config.js';
5
6
  export { flarePreprocessor, type FlarePreprocessorOptions } from './preprocessor.js';
6
7
  export type { FlareSvelteContext, SvelteErrorOrigin } from './types.js';
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import { flare } from '@flareapp/js';
2
2
  import { registerSvelteSdkIdentity } from './identify.js';
3
3
  import { registerDefaultFlare } from './resolveFlare.js';
4
- // Web entry. Importing @flareapp/js runs the root's own side effects (window.flare + global
5
- // catch) — correct for the web. Register the singleton as the default Flare AND set its SDK
6
- // identity AT IMPORT. The import-time identity registration is a hard contract: @flareapp/sveltekit
7
- // does `export * from '@flareapp/svelte'` and overrides the SDK name per-report, relying on this
8
- // running first (spec Decision 6). Do not defer it.
4
+ // Web entry. Importing @flareapp/js runs the root's side effects (window.flare + global catch),
5
+ // correct for the web. Registering the singleton and its SDK identity at import time is required:
6
+ // @flareapp/sveltekit does `export * from '@flareapp/svelte'` and overrides the SDK name per
7
+ // report, which only works if this ran first. Do not defer it.
9
8
  registerDefaultFlare(() => flare);
10
9
  registerSvelteSdkIdentity(flare);
11
10
  export { default as FlareErrorBoundary } from './FlareErrorBoundary.svelte';
12
11
  export { createFlareErrorHandler } from './createFlareErrorHandler.js';
13
12
  export { __flareRegisterComponent, getComponentTreeContext } from './componentTree.js';
13
+ export { __flareProfileComponent } from './profileComponent.js';
14
14
  export { withFlareConfig } from './config.js';
15
15
  export { flarePreprocessor } from './preprocessor.js';
package/dist/inject.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { default as FlareErrorBoundary } from './FlareErrorBoundary.svelte';
2
2
  export { createFlareErrorHandler, type FlareErrorHandlerOptions } from './createFlareErrorHandler.js';
3
3
  export { __flareRegisterComponent, getComponentTreeContext } from './componentTree.js';
4
+ export { __flareProfileComponent } from './profileComponent.js';
4
5
  export { withFlareConfig, type WithFlareConfigOptions } from './config.js';
5
6
  export { flarePreprocessor, type FlarePreprocessorOptions } from './preprocessor.js';
6
7
  export type { FlareSvelteContext, SvelteErrorOrigin } from './types.js';
package/dist/inject.js CHANGED
@@ -1,8 +1,9 @@
1
- // Electron-safe entry. NO @flareapp/js root import, NO default registration, NO import-time
2
- // identity. The caller MUST pass `flare` (handler option / boundary prop); resolveFlare throws
1
+ // Electron-safe entry. No @flareapp/js root import, no default registration, no import-time
2
+ // identity. The caller must pass `flare` (handler option / boundary prop); resolveFlare throws
3
3
  // at wiring time if absent.
4
4
  export { default as FlareErrorBoundary } from './FlareErrorBoundary.svelte';
5
5
  export { createFlareErrorHandler } from './createFlareErrorHandler.js';
6
6
  export { __flareRegisterComponent, getComponentTreeContext } from './componentTree.js';
7
+ export { __flareProfileComponent } from './profileComponent.js';
7
8
  export { withFlareConfig } from './config.js';
8
9
  export { flarePreprocessor } from './preprocessor.js';
@@ -1,6 +1,17 @@
1
+ import { type ProfileComponentsOption } from '@flareapp/core/util';
1
2
  import type { PreprocessorGroup } from 'svelte/compiler';
2
3
  export interface FlarePreprocessorOptions {
3
4
  exclude?: RegExp;
4
5
  importSource?: string;
6
+ /** Inject the component-tree registration used by error reports. */
7
+ componentTracking?: boolean;
8
+ /** Which components get a mount span. Matched against the route-aware profile name. */
9
+ profileComponents?: ProfileComponentsOption;
10
+ /** Project-relative routes directory, from `kit.files.routes`. */
11
+ routesDir?: string;
5
12
  }
13
+ /**
14
+ * Injects component-tree registration and, where configured, mount profiling into each `.svelte`
15
+ * file. Wire it into `svelte.config.js`'s `preprocess`, or use `withFlareConfig` to do that for you.
16
+ */
6
17
  export declare function flarePreprocessor(options?: FlarePreprocessorOptions): PreprocessorGroup;
@@ -1,62 +1,164 @@
1
+ import { createComponentMatcher, withoutStatefulFlags } from '@flareapp/core/util';
2
+ import MagicString from 'magic-string';
3
+ import { resolveProfileName } from './resolveProfileName.js';
4
+ // Loaded on demand so the compiler stays out of the entry's module graph. Costs nothing at
5
+ // runtime either: this hook only ever runs during the build's preprocessing pass.
6
+ let compiler;
7
+ function loadCompiler() {
8
+ compiler ??= import('svelte/compiler').catch((error) => {
9
+ // Otherwise one transient failure sticks to every remaining file in the build.
10
+ compiler = undefined;
11
+ throw error;
12
+ });
13
+ return compiler;
14
+ }
15
+ /**
16
+ * Injects component-tree registration and, where configured, mount profiling into each `.svelte`
17
+ * file. Wire it into `svelte.config.js`'s `preprocess`, or use `withFlareConfig` to do that for you.
18
+ */
1
19
  export function flarePreprocessor(options) {
2
- const exclude = options?.exclude;
20
+ const exclude = withoutStatefulFlags(options?.exclude);
3
21
  const importSource = options?.importSource ?? '@flareapp/svelte';
22
+ const componentTracking = options?.componentTracking ?? true;
23
+ const routesDir = options?.routesDir ?? 'src/routes';
24
+ const matchesProfile = createComponentMatcher(options?.profileComponents ?? false);
25
+ function buildInjection(filename) {
26
+ const profileName = resolveProfileName(filename, routesDir);
27
+ const shouldProfile = matchesProfile(profileName);
28
+ if (!componentTracking && !shouldProfile) {
29
+ return null;
30
+ }
31
+ const imports = [];
32
+ const statements = [];
33
+ if (componentTracking) {
34
+ imports.push('__flareRegisterComponent as __flare_reg__');
35
+ statements.push(`const __flare_node__ = __flare_reg__('${escapeString(extractComponentName(filename))}', '${escapeString(filename)}');`);
36
+ }
37
+ if (shouldProfile) {
38
+ imports.push('__flareProfileComponent as __flare_prof__');
39
+ statements.push(`__flare_prof__('${escapeString(profileName)}');`);
40
+ }
41
+ return `import { ${imports.join(', ')} } from '${importSource}';\n${statements.join('\n')}\n`;
42
+ }
4
43
  return {
5
44
  name: 'flare-component-tree',
6
- markup({ content, filename }) {
7
- if (!filename?.includes('.svelte')) {
8
- return;
9
- }
10
- if (exclude?.test(filename)) {
11
- return;
12
- }
13
- const hasScript = /<script[\s>]/i.test(content);
14
- if (hasScript) {
15
- return;
16
- }
17
- const componentName = extractComponentName(filename);
18
- const escapedFile = escapeString(filename);
19
- const injection = `<script>\n` +
20
- `import { __flareRegisterComponent as __flare_reg__ } from '${importSource}';\n` +
21
- `const __flare_node__ = __flare_reg__('${componentName}', '${escapedFile}');\n` +
22
- `</script>\n`;
23
- return {
24
- code: injection + content,
25
- };
26
- },
27
- script({ content, filename, attributes }) {
28
- if (!filename?.includes('.svelte')) {
29
- return;
30
- }
31
- if (exclude?.test(filename)) {
45
+ async markup({ content, filename }) {
46
+ if (!filename?.includes('.svelte') || exclude?.test(filename)) {
32
47
  return;
33
48
  }
34
- if (attributes.context === 'module' || attributes.module != null) {
49
+ const injection = buildInjection(filename);
50
+ if (!injection) {
35
51
  return;
36
52
  }
37
- // Skip a script the markup hook already injected. For a scriptless component the markup
38
- // hook adds a `<script>` with our registration; Svelte then runs THIS script hook over
39
- // that injected block within the same preprocessor pass. Without this guard we inject a
40
- // second time, producing a duplicate `const __flare_node__` -> "already been declared"
41
- // compile error.
42
- if (content.includes('__flare_node__')) {
53
+ const parsed = await instanceScriptStart(content, filename);
54
+ if (parsed === undefined) {
43
55
  return;
44
56
  }
45
- const componentName = extractComponentName(filename);
46
- const escapedFile = escapeString(filename);
47
- const injection = `import { __flareRegisterComponent as __flare_reg__ } from '${importSource}';\n` +
48
- `const __flare_node__ = __flare_reg__('${componentName}', '${escapedFile}');\n`;
49
- return {
50
- code: injection + content,
51
- };
57
+ return injectWithMap(content, injection, filename, parsed.start, parsed.bomCount);
52
58
  },
53
59
  };
54
60
  }
61
+ /** Keeps the merged sourcemap's `sources` from carrying an absolute build-machine path. */
62
+ function basename(filename) {
63
+ return filename.split(/[/\\]/).pop() ?? filename;
64
+ }
65
+ /**
66
+ * The name error reports use. Stays a bare basename rather than reusing `resolveProfileName`, because
67
+ * changing it would change component hierarchies people already have.
68
+ */
55
69
  function extractComponentName(filename) {
56
- const normalized = filename.replace(/\\/g, '/');
57
- const base = normalized.split('/').pop() ?? filename;
58
- return base.replace(/\.svelte$/, '');
70
+ return basename(filename).replace(/\.svelte$/, '');
59
71
  }
60
72
  function escapeString(str) {
61
73
  return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
62
74
  }
75
+ /** Copied from Svelte's own preprocessor, so we agree with it on what counts as a style tag. */
76
+ const REGEX_STYLE_TAGS = /<!--[^]*?-->|<style((?:\s+[^=>'"/\s]+=(?:"[^"]*"|'[^']*'|[^>\s]+)|\s+[^=>'"/\s]+)*\s*)(?:\/>|>([\S\s]*?)<\/style>)/g;
77
+ const CLOSING_STYLE_TAG = '</style>';
78
+ /** One warning per file per process. A dev server runs this hook again on every save. */
79
+ const warnedFiles = new Set();
80
+ /**
81
+ * Blanks out every `<style>` body, keeping the exact character count so offsets into the original
82
+ * still line up. Svelte parses style bodies as CSS, so `lang="scss"` and friends throw and would
83
+ * otherwise cost the file its registration.
84
+ */
85
+ function blankStyleBodies(content) {
86
+ return content.replace(REGEX_STYLE_TAGS, (match, _attributes, body) => {
87
+ // The first branch of the regex matches comments, which have no body to blank.
88
+ if (body === undefined || match.startsWith('<!--')) {
89
+ return match;
90
+ }
91
+ const bodyStart = match.length - body.length - CLOSING_STYLE_TAG.length;
92
+ // Newlines survive so reported line numbers keep matching the real file.
93
+ return match.slice(0, bodyStart) + body.replace(/[^\n]/g, ' ') + match.slice(bodyStart + body.length);
94
+ });
95
+ }
96
+ function warnOnce(filename, reason) {
97
+ if (warnedFiles.has(filename)) {
98
+ return;
99
+ }
100
+ warnedFiles.add(filename);
101
+ // Silence here reads as "component tracking works", which is worse than a noisy build.
102
+ console.warn(`[flare] Skipped component tracking for ${filename}: ${reason}`);
103
+ }
104
+ /** How many BOM characters sit at the very start of the source, back to back. */
105
+ function countLeadingBoms(content) {
106
+ let count = 0;
107
+ while (content.charCodeAt(count) === 0xfeff) {
108
+ count++;
109
+ }
110
+ return count;
111
+ }
112
+ /**
113
+ * Where the instance script's body begins, `null` when the component has none, `undefined` when the
114
+ * source cannot be parsed. Svelte hands a script hook every `<script>` in the file, nested ones
115
+ * included, so only the parser can say which one belongs to the component. `bomCount` rides along
116
+ * because the null case still needs to know how many bytes of BOM it must insert after.
117
+ */
118
+ async function instanceScriptStart(content, filename) {
119
+ // parse() strips exactly one leading BOM itself and reports offsets against the stripped source.
120
+ // Stripping every leading BOM here (not just one) before parsing means none are left for parse()'s
121
+ // own stripping to act on, so the count we add back is exact no matter how many there were.
122
+ const bomCount = countLeadingBoms(content);
123
+ const source = content.slice(bomCount);
124
+ try {
125
+ const { parse } = await loadCompiler();
126
+ const root = parse(blankStyleBodies(source), { modern: true, filename });
127
+ if (!root.instance) {
128
+ return { start: null, bomCount };
129
+ }
130
+ const start = root.instance.content.start;
131
+ return { start: start + bomCount, bomCount };
132
+ }
133
+ catch (error) {
134
+ // Half-written source, or a template another preprocessor still has to turn into Svelte.
135
+ // Skipping costs a registration; guessing corrupts the file.
136
+ warnOnce(filename, error instanceof Error ? error.message : String(error));
137
+ return undefined;
138
+ }
139
+ }
140
+ /** The map matters: inserting lines shifts everything below, throwing off stack frames and breakpoints. */
141
+ function injectWithMap(content, injection, filename, start, bomCount) {
142
+ const magicSource = new MagicString(content);
143
+ if (start === null) {
144
+ const scriptBlock = `<script>\n${injection}</script>\n`;
145
+ // prepend() inserts at offset 0, which would land ahead of the BOM(s) and move them into the
146
+ // template. appendRight(bomCount, ...) inserts right after all of them instead, keeping every
147
+ // BOM at the front so compile()'s own BOM stripping still fires.
148
+ if (bomCount > 0) {
149
+ magicSource.appendRight(bomCount, scriptBlock);
150
+ }
151
+ else {
152
+ magicSource.prepend(scriptBlock);
153
+ }
154
+ }
155
+ else {
156
+ magicSource.appendLeft(start, `\n${injection}`);
157
+ }
158
+ return {
159
+ code: magicSource.toString(),
160
+ // Basename, not the full path: the merged map inherits `sources` from the oldest map in
161
+ // the chain, which is ours, so an absolute path here ships in every built sourcemap.
162
+ map: magicSource.generateMap({ hires: true, source: basename(filename) }),
163
+ };
164
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Records one `browser_component` span for this component's mount. The preprocessor injects the call,
3
+ * don't write it by hand.
4
+ *
5
+ * Init runs top-down, so a child can point at a span id its parent reserved but hasn't recorded yet.
6
+ */
7
+ export declare function __flareProfileComponent(name: string): void;
@@ -0,0 +1,47 @@
1
+ import { activeComponentRoot, nowNano, recordComponentSpan, reserveSpanId, resolveComponentParent, } from '@flareapp/js/browser';
2
+ import { getContext, onMount, setContext } from 'svelte';
3
+ // Separate from the component tree's key: that one chains every component, this one only the
4
+ // profiled ones.
5
+ const PROFILE_KEY = '__flare_component_profile';
6
+ /**
7
+ * Records one `browser_component` span for this component's mount. The preprocessor injects the call,
8
+ * don't write it by hand.
9
+ *
10
+ * Init runs top-down, so a child can point at a span id its parent reserved but hasn't recorded yet.
11
+ */
12
+ export function __flareProfileComponent(name) {
13
+ try {
14
+ const inherited = getContext(PROFILE_KEY) ?? null;
15
+ // A layout that survives a navigation still holds the old trace, so re-home rather than
16
+ // record onto one that already shipped.
17
+ const parent = resolveComponentParent(inherited, activeComponentRoot());
18
+ if (!parent) {
19
+ // Tracing off, no root open, or SSR. Publishing no context keeps us transparent:
20
+ // descendants resolve against the live root themselves.
21
+ return;
22
+ }
23
+ const spanId = reserveSpanId(parent.traceId);
24
+ if (!spanId) {
25
+ return;
26
+ }
27
+ const startTimeUnixNano = nowNano();
28
+ setContext(PROFILE_KEY, { traceId: parent.traceId, parentSpanId: spanId });
29
+ onMount(() => {
30
+ try {
31
+ recordComponentSpan({
32
+ name,
33
+ spanId,
34
+ parent,
35
+ startTimeUnixNano,
36
+ endTimeUnixNano: nowNano(),
37
+ });
38
+ }
39
+ catch {
40
+ // instrumentation must never break the host
41
+ }
42
+ });
43
+ }
44
+ catch {
45
+ // instrumentation must never break the host
46
+ }
47
+ }
@@ -1,3 +1,2 @@
1
- import type { Flare } from '@flareapp/js/browser';
2
- export declare function registerDefaultFlare(provider: () => Flare): void;
3
- export declare function resolveFlare(explicit?: Flare): Flare;
1
+ declare const registerDefaultFlare: (provider: () => import("@flareapp/js").Flare) => void, resolveFlare: (explicit?: import("@flareapp/js").Flare) => import("@flareapp/js").Flare;
2
+ export { registerDefaultFlare, resolveFlare };
@@ -1,42 +1,6 @@
1
- let defaultProvider = null;
2
- // `process.env.NODE_ENV` is replaced inline by bundlers (vite/webpack). The try/catch keeps a
3
- // process-less environment safe: treat "undetermined" as production (warn, never crash).
4
- function isDevMode() {
5
- try {
6
- return process.env.NODE_ENV !== 'production';
7
- }
8
- catch {
9
- return false;
10
- }
11
- }
12
- // Called once by the web entry (index.ts) as an import side effect.
13
- export function registerDefaultFlare(provider) {
14
- // Tripwire: a web default registering while the Electron bridge exists means a renderer pulled
15
- // the package root — directly, or via component-tracking codegen emitting the root specifier
16
- // (set the preprocessor's importSource to '@flareapp/svelte/inject' to avoid that). It drags
17
- // the keyed @flareapp/js singleton and its global side effects into the renderer.
18
- if (typeof window !== 'undefined' && window.__flare) {
19
- const message = '[flare] @flareapp/svelte (web root) was imported in a renderer where the Electron ' +
20
- 'bridge is present, pulling the keyed @flareapp/js singleton into the renderer. ' +
21
- "Import @flareapp/svelte/inject (and set the preprocessor importSource to '@flareapp/svelte/inject') instead.";
22
- // Dev: throw so the misconfiguration is impossible to miss. Production: warn instead, so a
23
- // shipped app isn't crashed by a (recoverable) reporting-setup mistake.
24
- if (isDevMode()) {
25
- throw new Error(message);
26
- }
27
- console.warn(message);
28
- }
29
- defaultProvider = provider;
30
- }
31
- // Resolve at WIRING time (handler creation / component setup), never inside a report path.
32
- export function resolveFlare(explicit) {
33
- if (explicit) {
34
- return explicit;
35
- }
36
- if (defaultProvider) {
37
- return defaultProvider();
38
- }
39
- throw new Error('[flare] No Flare instance available. Pass `flare` (e.g. from ' +
40
- '@flareapp/electron/renderer), or import @flareapp/svelte (the package root) ' +
41
- 'to use the @flareapp/js default singleton.');
42
- }
1
+ import { createFlareResolver } from '@flareapp/js/browser';
2
+ const { registerDefaultFlare, resolveFlare } = createFlareResolver({
3
+ packageName: '@flareapp/svelte',
4
+ injectInstruction: "Import @flareapp/svelte/inject (and set the preprocessor importSource to '@flareapp/svelte/inject') instead.",
5
+ });
6
+ export { registerDefaultFlare, resolveFlare };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The name a profiled component reports, and what `profileComponents` matches against.
3
+ *
4
+ * Separate from `extractComponentName`, which feeds error reports and has to keep its bare basenames.
5
+ * Profiling needs the route path too, otherwise every route in a SvelteKit app is just `+page`.
6
+ *
7
+ * @param filename Absolute path as a Svelte preprocessor receives it.
8
+ * @param routesDir Project-relative routes directory, from `kit.files.routes`.
9
+ */
10
+ export declare function resolveProfileName(filename: string, routesDir?: string): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The name a profiled component reports, and what `profileComponents` matches against.
3
+ *
4
+ * Separate from `extractComponentName`, which feeds error reports and has to keep its bare basenames.
5
+ * Profiling needs the route path too, otherwise every route in a SvelteKit app is just `+page`.
6
+ *
7
+ * @param filename Absolute path as a Svelte preprocessor receives it.
8
+ * @param routesDir Project-relative routes directory, from `kit.files.routes`.
9
+ */
10
+ export function resolveProfileName(filename, routesDir = 'src/routes') {
11
+ const normalized = filename.replace(/\\/g, '/');
12
+ const base = normalized.split('/').pop() ?? normalized;
13
+ const name = base.replace(/\.svelte$/, '');
14
+ // Only route files clash with each other. An ordinary component name is already fine as is.
15
+ if (!name.startsWith('+')) {
16
+ return name;
17
+ }
18
+ // Users can write `./src/routes` or `src/routes/`, both legal, so flatten before searching.
19
+ const normalizedRoutesDir = routesDir
20
+ .replace(/\\/g, '/')
21
+ .replace(/^\.\//, '')
22
+ .replace(/^\/+|\/+$/g, '');
23
+ const start = routeDirStart(normalized, normalizedRoutesDir);
24
+ if (start === -1) {
25
+ return name;
26
+ }
27
+ const relativeDir = normalized.slice(start, normalized.lastIndexOf('/'));
28
+ return relativeDir ? `${relativeDir}/${name}` : name;
29
+ }
30
+ /**
31
+ * Index just past the routes directory, or -1 when the path isn't in there. Anchors on the last
32
+ * occurrence, so a project checked out under something like /home/src/routes/ still works.
33
+ */
34
+ function routeDirStart(normalized, normalizedRoutesDir) {
35
+ const nested = normalized.lastIndexOf(`/${normalizedRoutesDir}/`);
36
+ if (nested !== -1) {
37
+ return nested + normalizedRoutesDir.length + 2;
38
+ }
39
+ const prefix = `${normalizedRoutesDir}/`;
40
+ return normalized.startsWith(prefix) ? prefix.length : -1;
41
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const PACKAGE_VERSION = "2.6.0";
1
+ export declare const PACKAGE_VERSION = "2.8.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // generated during release, do not modify
2
- export const PACKAGE_VERSION = '2.6.0';
2
+ export const PACKAGE_VERSION = '2.8.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flareapp/svelte",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Svelte client for flareapp.io",
5
5
  "homepage": "https://flareapp.io",
6
6
  "bugs": "https://github.com/spatie/flare-client-js/issues",
@@ -14,12 +14,7 @@
14
14
  "email": "info@spatie.be"
15
15
  },
16
16
  "contributors": [
17
- "Adriaan Marain <adriaan@spatie.be>",
18
- "Alex Vanderbist <alex@spatie.be>",
19
- "Dries Heyninck <dries@spatie.be>",
20
- "Freek Van der Herten <freek@spatie.be>",
21
- "Sebastian De Deyne <sebastian@spatie.be>",
22
- "Sébastien Henau <seba@spatie.be>"
17
+ "Dries Heyninck <dries@spatie.be>"
23
18
  ],
24
19
  "type": "module",
25
20
  "files": [
@@ -67,6 +62,8 @@
67
62
  "devDependencies": {
68
63
  "@flareapp/electron": "file:../electron",
69
64
  "@flareapp/js": "file:../js",
65
+ "@flareapp/test-helpers": "*",
66
+ "@jridgewell/trace-mapping": "^0.3.31",
70
67
  "@sveltejs/package": "^2.5.7",
71
68
  "@sveltejs/vite-plugin-svelte": "^5.0.0",
72
69
  "@testing-library/svelte": "^5.0.0",
@@ -76,14 +73,15 @@
76
73
  "vitest": "^4.0.0"
77
74
  },
78
75
  "peerDependencies": {
79
- "@flareapp/js": "^2.6.0",
76
+ "@flareapp/js": "^2.8.0",
80
77
  "svelte": "^5.3.0"
81
78
  },
82
79
  "publishConfig": {
83
80
  "access": "public"
84
81
  },
85
82
  "dependencies": {
86
- "@flareapp/core": "2.6.0",
87
- "error-stack-parser": "^2.1.4"
83
+ "@flareapp/core": "2.8.0",
84
+ "error-stack-parser": "^2.1.4",
85
+ "magic-string": "^0.30.21"
88
86
  }
89
87
  }