@wireai/activation 0.14.1 → 0.14.3

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.
@@ -1,8 +1,8 @@
1
- import Onboarding, {
2
- type OnboardingColors,
3
- type OnboardingFonts,
4
- type OnboardingProps,
5
- type OnboardingStep,
1
+ import type {
2
+ OnboardingColors,
3
+ OnboardingFonts,
4
+ OnboardingProps,
5
+ OnboardingStep,
6
6
  } from "@blazejkustra/react-native-onboarding";
7
7
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { type ImageSourcePropType, StyleSheet, View } from "react-native";
@@ -12,6 +12,7 @@ import { hasSeenGate, markSeenGate, showcaseGateKey } from "../coachmarks/runtim
12
12
  import { useResolvedFeatures } from "../features/WireFeaturesProvider";
13
13
  import { useOnboardingTheme } from "../theme/ThemeContext";
14
14
  import type { OnboardingTheme } from "../theme/types";
15
+ import { resolveShowcaseOnboarding } from "./blazejOnboarding";
15
16
  import { showcaseColorsFromTheme, showcasePanelBackground } from "./showcaseColors";
16
17
  import type { FeatureShowcaseProps } from "./types";
17
18
 
@@ -47,6 +48,13 @@ const mergeThemeOver = (
47
48
  *
48
49
  * When the gate says "seen", it renders nothing and calls `onDone` from an
49
50
  * effect (never during render).
51
+ *
52
+ * The underlying package is an OPTIONAL peer, resolved through the guarded lazy
53
+ * require in `blazejOnboarding.ts` rather than a static import, so the showcase
54
+ * subpath builds on a host that never installed it. Absent, this takes the SAME
55
+ * path as the kill switch — render null, call `onDone`, write no gate — so the
56
+ * host's flow always advances and the showcase still plays once if the peer is
57
+ * added later.
50
58
  */
51
59
  const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
52
60
  config,
@@ -71,14 +79,20 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
71
79
  const flags = useResolvedFeatures({ flags: features, config: featuresConfig });
72
80
  const disabled = !flags.showcase.enabled;
73
81
 
82
+ // The optional pager peer. Resolved once, on first render, instead of at module scope — so
83
+ // merely importing this file (and thus the showcase subpath) never needs it. Absent → treated
84
+ // exactly like the kill switch below: no render, no gate write, `onDone` from the effect.
85
+ const Onboarding = useMemo(() => resolveShowcaseOnboarding(), []);
86
+
74
87
  const gateKey = showcaseGateKey(config.id);
75
88
  const seen = useMemo(
76
89
  () => hasSeenGate(gateKey, storage, isTesting),
77
90
  [gateKey, storage, isTesting],
78
91
  );
79
- // Either already-seen OR feature-disabled short-circuits the showcase. Only the seen path is a
80
- // gate write (in `finish`); the disabled path never persists, so it replays when re-enabled.
81
- const skip = seen || disabled;
92
+ // Already-seen, feature-disabled OR the peer missing short-circuits the showcase. Only the seen
93
+ // path is a gate write (in `finish`); the other two never persist, so the showcase replays once
94
+ // the feature is re-enabled or the peer is installed.
95
+ const skip = seen || disabled || !Onboarding;
82
96
 
83
97
  const doneRef = useRef(false);
84
98
  const finish = useCallback(() => {
@@ -177,7 +191,9 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
177
191
  [t],
178
192
  );
179
193
 
180
- if (skip) return null;
194
+ // `!Onboarding` is already folded into `skip`; it is repeated here so the narrowing is explicit
195
+ // to the reader and to the compiler at the JSX below.
196
+ if (skip || !Onboarding) return null;
181
197
 
182
198
  const activeGesture = config.slides[activeIndex]?.gesture;
183
199
  // Same rule as the coachmark path: tap / double-tap slides show no glyph.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * blazejOnboarding — resolve `@blazejkustra/react-native-onboarding`, lazily and optionally.
3
+ *
4
+ * ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
5
+ *
6
+ * `@blazejkustra/react-native-onboarding` is an OPTIONAL peer of the showcase subpath — it is the
7
+ * slide pager `FeatureShowcase` wraps. Before this file, `FeatureShowcase.tsx` imported its default
8
+ * export at module scope, and `showcase/index.ts` re-exports the component with no wildcard escape,
9
+ * so importing ANYTHING from `@wireai/activation/showcase` (even `selectShowcaseSlides`) dragged
10
+ * the peer in, and a host that skipped the "optional" peer hit a Metro resolution failure. This
11
+ * resolver removes that static edge. Same class and same shape as the 0.14.2 `expo-blur` fix
12
+ * (`coachmarks/expoBlur.ts`), whose header carries the reasoning in full.
13
+ *
14
+ * The TYPES stay statically imported on purpose: a type-only import is erased by every toolchain
15
+ * (babel, esbuild and sucrase all drop it), so it creates no runtime edge and no bundle dependency,
16
+ * and `showcaseColors.ts` already relies on exactly that.
17
+ *
18
+ * ── THE SPECIFIER MUST BE A STRING LITERAL, INSIDE A TRY/CATCH ───────────────────────────
19
+ *
20
+ * Metro collects dependencies statically and matches only a call whose callee is literally the
21
+ * identifier `require` and whose argument is literally a string; the try/catch around it is what
22
+ * marks the dependency `isOptional`. Both halves are load-bearing — see `icons/expoIcons.ts` for
23
+ * the full autopsy of the 0.8.0 shape that aliased the callee and was collected nowhere.
24
+ *
25
+ * ── THE DEGRADE ─────────────────────────────────────────────────────────────────────────
26
+ *
27
+ * Absent → `FeatureShowcase` renders null and calls `onDone` from an effect, which is the SAME path
28
+ * the feature kill switch already takes. The host's flow always advances (a showcase that cannot
29
+ * render must never strand the user on a blank screen), and the seen-gate is deliberately NOT
30
+ * written, so installing the peer later still plays the showcase once.
31
+ */
32
+ import type { ComponentType } from "react";
33
+
34
+ import type { OnboardingProps } from "@blazejkustra/react-native-onboarding";
35
+
36
+ // Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own tests
37
+ // run under `node --test` as ESM). Declared locally so this type-checks without ambient Node types;
38
+ // the `typeof` guard keeps the reference ESM-safe.
39
+ declare const require: ((id: string) => unknown) | undefined;
40
+
41
+ /** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
42
+ export type OptionalRequire = (moduleName: string) => unknown;
43
+
44
+ /** The pager component `FeatureShowcase` wraps. */
45
+ export type OnboardingComponent = ComponentType<OnboardingProps>;
46
+
47
+ const MODULE_NAME = "@blazejkustra/react-native-onboarding";
48
+
49
+ /**
50
+ * The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
51
+ * `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
52
+ * than the one module this file owns resolves to undefined.
53
+ */
54
+ const runtimeRequire: OptionalRequire = (moduleName) => {
55
+ if (moduleName !== MODULE_NAME) return undefined;
56
+ if (typeof require !== "function") return undefined;
57
+ try {
58
+ return require("@blazejkustra/react-native-onboarding");
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ };
63
+
64
+ /**
65
+ * TEST-ONLY seam. `FeatureShowcase`'s public props are frozen, so it cannot take a `requireModule`
66
+ * the way `WireIcon` does — this lets a component render exercise the PRESENT path without the peer
67
+ * installed. Production never sets it; `runtimeRequire` is the only resolver.
68
+ */
69
+ let testRequire: OptionalRequire | undefined;
70
+ export const __setShowcaseOnboardingRequireForTests = (fn: OptionalRequire | undefined): void => {
71
+ testRequire = fn;
72
+ cached = undefined;
73
+ };
74
+
75
+ /**
76
+ * Read the component off the module. The package ships it as a DEFAULT export; a CJS build may put
77
+ * the component directly on `module.exports` instead, so both shapes are accepted.
78
+ */
79
+ const interop = (mod: unknown): unknown => {
80
+ if (typeof mod === "function") return mod;
81
+ if (!mod || typeof mod !== "object") return undefined;
82
+ const ns = mod as Record<string, unknown>;
83
+ if (ns.default) return ns.default;
84
+ // `module.exports = forwardRef(...)`: the component IS the namespace, and it is an object rather
85
+ // than a function, so it would slip past the check above.
86
+ if ("$$typeof" in ns) return ns;
87
+ return undefined;
88
+ };
89
+
90
+ /**
91
+ * Narrow an unknown export to something mountable: a real component (function or class) or a
92
+ * React.memo / forwardRef wrapper (an object carrying `$$typeof`). Reject anything else rather than
93
+ * handing the reconciler a non-component.
94
+ */
95
+ const asComponent = (value: unknown): OnboardingComponent | undefined => {
96
+ if (typeof value === "function") return value as OnboardingComponent;
97
+ if (value && typeof value === "object" && "$$typeof" in (value as object)) {
98
+ return value as OnboardingComponent;
99
+ }
100
+ return undefined;
101
+ };
102
+
103
+ /**
104
+ * Module-level memo. `null` = "we looked and it is not there" (distinct from "not looked yet"), so
105
+ * an absent peer costs exactly one failed require per process, not one per showcase mount.
106
+ */
107
+ let cached: OnboardingComponent | null | undefined;
108
+
109
+ /** Reset the memo. TEST-ONLY seam — production never calls it. */
110
+ export const resetShowcaseOnboardingCache = (): void => {
111
+ cached = undefined;
112
+ };
113
+
114
+ /**
115
+ * Resolve the pager component, or undefined when the peer is absent/unresolvable. Never throws: an
116
+ * absent showcase must advance the host's flow, never break it.
117
+ *
118
+ * `requireModule` is injectable so tests can exercise BOTH the found and absent paths without
119
+ * installing the peer (same convention as `resolveBlurView` / `resolveIconFamily`).
120
+ */
121
+ export const resolveShowcaseOnboarding = (
122
+ requireModule: OptionalRequire = testRequire ?? runtimeRequire,
123
+ ): OnboardingComponent | undefined => {
124
+ try {
125
+ if (cached === undefined || requireModule !== runtimeRequire) {
126
+ const component = asComponent(interop(requireModule(MODULE_NAME)));
127
+ // Don't poison the module memo from an injected test require.
128
+ if (requireModule === runtimeRequire) cached = component ?? null;
129
+ return component;
130
+ }
131
+ if (cached === null) return undefined;
132
+ return cached;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ };
@@ -2,10 +2,14 @@
2
2
  * @wireai/activation/showcase — the pre-onboarding feature showcase ("app intro").
3
3
  *
4
4
  * Subpath entry, kept OUT of the main barrel so the core kit stays dependency-
5
- * free: importing this pulls in the optional peer
6
- * `@blazejkustra/react-native-onboarding` (and, transitively via GestureHint,
7
- * `react-native-reanimated`). The app supplies a declarative ShowcaseConfig; the
8
- * kit bakes in the Wire theme + optional per-slide gesture hand and gates once.
5
+ * free. Importing this pulls in NO native peer (0.14.3): the pager
6
+ * `@blazejkustra/react-native-onboarding` is reached through the guarded lazy
7
+ * require in `blazejOnboarding.ts`, and the transitive edge through GestureHint
8
+ * to `react-native-reanimated` is guarded the same way in `coachmarks/reanimated.ts`.
9
+ * Without the pager the showcase renders null and calls `onDone` so the host's
10
+ * flow advances; without reanimated the gesture glyph renders static. The app
11
+ * supplies a declarative ShowcaseConfig; the kit bakes in the Wire theme +
12
+ * optional per-slide gesture hand and gates once.
9
13
  *
10
14
  * import { FeatureShowcase, selectShowcaseSlides } from "@wireai/activation/showcase";
11
15
  */