Skip to content

Add a global preview configuration applied to every preview - #174

Open
BarredEwe wants to merge 3 commits into
feature/drop-sourceryfrom
feature/global-preview-wrapper
Open

Add a global preview configuration applied to every preview#174
BarredEwe wants to merge 3 commits into
feature/drop-sourceryfrom
feature/global-preview-wrapper

Conversation

@BarredEwe

@BarredEwe BarredEwe commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Closes #71

Stacked on #177. Base is feature/drop-sourcery, so the diff shown here is just the global-configuration work. GitHub retargets this to main automatically once #177 merges. Automatic detection below needs the type scanner from that PR.

Today every #Preview has to wire up its own environment — theme, DI, locale, mocks. This adds a way to declare that once for the whole test target and Playbook.

enum MyPrefireSetup: PrefireGlobalConfiguration {
    static func wrap(_ view: AnyView) -> AnyView {
        AnyView(view.environment(\.locale, .init(identifier: "en_US")).environmentObject(DesignSystem.dark))
    }
}

That is the whole setup — no .prefire.yml entry. A single type conforming to PrefireGlobalConfiguration anywhere in sources is found and used.

Resolution

The type is resolved in Swift rather than in the template, which is what makes the diagnostics possible:

Situation Result
exactly one reachable conformer used, and logged
several generation fails and lists them, instead of picking one by an ordering nobody chose
private / fileprivate skipped, with a warning saying why — rather than a compile error pointing at the generated file
none nothing changes

global_configuration: stays and always wins. It is the escape hatch for what detection cannot cover: a configuration declared in the test target (only sources is scanned), and different wrappers for tests and the Playbook. A configured name is never validated against the sources, which is what lets the test-target case work.

Generated code

Each generated file resolves the type once at file scope and passes that constant everywhere:

private let prefireGlobalConfiguration: (any PrefireGlobalConfiguration.Type)? = MyPrefireSetup.self

The alternative was interpolating the type name behind {% if %} at each of the six call sites, which also meant toggling @inlinable on createModel depending on a config key. @inlinable is dropped instead: createModel is an internal function in a file compiled into the user's own module, so without @usableFromInline on what it touches the attribute bought nothing.

The cost is that the generated file is no longer byte-identical to before when no key is set — it gains the resolving line and the extra argument. The shape is stable either way.

Where the wrapper is applied

In tests, inside PrefireSnapshot.content, before onPreferenceChange and before .frame / fixedSize, so collecting delay/precision/record and userStory/state keeps working. In the Playbook, in PreviewModel's designated initializer, which also makes it work for NSView/UIView previews where wrapping in AnyView inside the template would not compile.

Two things worth knowing, both documented:

  • Turning this on changes rendered sizes. The wrapper sits inside the layout Prefire applies, so a wrap(_:) that adds padding or a container changes measured heights — existing snapshots need re-recording.
  • Do not apply it twice. PreviewModel already wraps its content, so hand-written tests built on PreviewModels.models should not also pass globalConfiguration: to PrefireSnapshot.

Tests

Config decoding with and without the key, makeArguments, template rendering with the key set and unset for both templates, end-to-end detection and the ambiguous-conformance failure, ten GlobalConfigurationResolverTests covering detection through an intermediate protocol, module-qualified conformance, nested types, unreachable access levels and the configured-name-wins case, and three runtime tests in MacOSSnapshotTests covering that the wrapper is applied, that preferences still arrive, and that the default implementation changes nothing.

make test 90/0, make test-cli 20/0.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9908ce8b6a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

case useGroupedSnapshots = "use_grouped_snapshots"
case splitSnapshotDirectories = "split_snapshot_directories"
case drawHierarchyInKeyWindowDefaultEnabled = "draw_hierarchy_in_key_window_default_enabled"
case globalConfiguration = "global_configuration"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rebuild the executable artifact with the new configuration

When Prefire is consumed through this package's CLI or build-tool plugins, Package.swift launches the checked-in Binaries/PrefireBinary.artifactbundle, but this commit does not update that artifact and its binary contains neither global_configuration nor the new templates. Consequently, users depending on this commit get an unknown-key warning and unwrapped previews even though this coding key and the documentation advertise the feature; rebuild and commit the bundled executable alongside these source changes.

Useful? React with 👍 / 👎.

Previews had to repeat the same environment setup — theme, DI, locale,
mocks — in every `#Preview`. A type conforming to the new
`PrefireGlobalConfiguration` protocol now describes that setup once, and
`global_configuration:` in `test_configuration` / `playbook_configuration`
points the generator at it.

The generated code passes the type to `PrefireSnapshot` / `PreviewModel`,
which wrap the preview content before rendering — inside the preference
readers, so `.snapshot(delay:precision:)` and `.previewUserStory()` keep
working. Without the key the generated code is unchanged.

Closes #71.
The type name was interpolated at all six call sites behind
`{% if argument.globalConfiguration %}`, and the presence of the key also
toggled `@inlinable` on `createModel`. Both are now gone: each generated file
declares

    private let prefireGlobalConfiguration: (any PrefireGlobalConfiguration.Type)? = ...

resolving to the configured type or to `nil`, and every call site passes that
constant unconditionally.

`@inlinable` is dropped rather than made conditional. `createModel` is an
internal function in a file compiled into the user's own module, so without
`@usableFromInline` on what it touches the attribute bought nothing — and
making generated output depend on a config key in that way is worse than not
having it.

The generated file is no longer byte-identical to before when no key is set: it
gains the resolving line and the extra argument. That is the cost of not
branching in six places, and the shape is stable either way.
`global_configuration:` had to be set for the wrapper to be applied at all. Now
a single type conforming to `PrefireGlobalConfiguration` anywhere in `sources`
is found by the type scan and used, so the common case needs no configuration.

Resolution happens in Swift rather than in the template, which is what makes the
diagnostics possible:

- exactly one reachable conformer — it is used, and logged;
- several — generation fails and lists them, instead of picking one by an
  ordering nobody chose;
- `private` / `fileprivate` — skipped with a warning saying why, rather than a
  compile error pointing at the generated file;
- none — nothing changes.

The key stays, and always wins. It is the escape hatch for the cases detection
cannot cover: a configuration declared in the test target (only `sources` is
scanned), and different wrappers for tests and the Playbook. A configured name
is never validated against the sources, which is what lets the test-target case
work at all.
@BarredEwe
BarredEwe force-pushed the feature/global-preview-wrapper branch from 9908ce8 to 87a8fc8 Compare September 6, 2026 14:24
@BarredEwe
BarredEwe changed the base branch from main to feature/drop-sourcery September 6, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Globally define preview appearance

1 participant